Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dc0384c78 | ||
|
|
17b6dd2041 | ||
|
|
0a59059738 | ||
|
|
bfcf6e48b2 |
No files matched your search
@@ -991,6 +991,23 @@ paths:
|
|||||||
$ref: "#/components/schemas/LibraryErrorPage"
|
$ref: "#/components/schemas/LibraryErrorPage"
|
||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/Problem"
|
$ref: "#/components/responses/Problem"
|
||||||
|
/api/v1/admin/library/analysis/errors:
|
||||||
|
get:
|
||||||
|
operationId: listAnalysisErrors
|
||||||
|
tags: [library]
|
||||||
|
summary: Неудавшиеся задачи аудиоанализа с путями файлов (admin)
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/Limit"
|
||||||
|
- $ref: "#/components/parameters/Cursor"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Страница ошибок аудиоанализа, упорядоченная по ID трека
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/AnalysisErrorPage"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
/api/v1/admin/library/host-dirs:
|
/api/v1/admin/library/host-dirs:
|
||||||
get:
|
get:
|
||||||
operationId: listHostDirs
|
operationId: listHostDirs
|
||||||
@@ -1707,6 +1724,54 @@ paths:
|
|||||||
'204':
|
'204':
|
||||||
description: Deleted
|
description: Deleted
|
||||||
|
|
||||||
|
/api/v1/me/playlists/{id}/share:
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema: { type: string, format: uuid }
|
||||||
|
post:
|
||||||
|
tags: [collection]
|
||||||
|
operationId: sharePlaylist
|
||||||
|
summary: Создать или вернуть ссылку только для чтения (только владелец)
|
||||||
|
responses:
|
||||||
|
default:
|
||||||
|
$ref: '#/components/responses/Problem'
|
||||||
|
'200':
|
||||||
|
description: Идентификатор ссылки для /shared/playlist/{shareId}; для открытия требуется вход
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PlaylistShare'
|
||||||
|
delete:
|
||||||
|
tags: [collection]
|
||||||
|
operationId: revokePlaylistShare
|
||||||
|
summary: Отозвать ссылку (только владелец); повторная публикация создаёт новую ссылку
|
||||||
|
responses:
|
||||||
|
default:
|
||||||
|
$ref: '#/components/responses/Problem'
|
||||||
|
'204':
|
||||||
|
description: Доступ по прежней ссылке отключён
|
||||||
|
/api/v1/shared/playlists/{shareId}:
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: shareId
|
||||||
|
required: true
|
||||||
|
schema: { type: string, format: uuid }
|
||||||
|
get:
|
||||||
|
tags: [collection]
|
||||||
|
operationId: getSharedPlaylist
|
||||||
|
summary: Прочитать плейлист по действующей ссылке (требуется вход)
|
||||||
|
responses:
|
||||||
|
default:
|
||||||
|
$ref: '#/components/responses/Problem'
|
||||||
|
'200':
|
||||||
|
description: Плейлист с реакциями и ссылками на стрим для текущего слушателя
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Playlist'
|
||||||
|
|
||||||
components:
|
components:
|
||||||
parameters:
|
parameters:
|
||||||
SourceId:
|
SourceId:
|
||||||
@@ -1767,6 +1832,11 @@ components:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Problem"
|
$ref: "#/components/schemas/Problem"
|
||||||
schemas:
|
schemas:
|
||||||
|
PlaylistShare:
|
||||||
|
type: object
|
||||||
|
required: [share_id]
|
||||||
|
properties:
|
||||||
|
share_id: { type: string, format: uuid }
|
||||||
PlaylistInput:
|
PlaylistInput:
|
||||||
type: object
|
type: object
|
||||||
required: [name, description, track_ids]
|
required: [name, description, track_ids]
|
||||||
@@ -1803,6 +1873,12 @@ components:
|
|||||||
- type: object
|
- type: object
|
||||||
required: [tracks]
|
required: [tracks]
|
||||||
properties:
|
properties:
|
||||||
|
share_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
nullable: true
|
||||||
|
readOnly: true
|
||||||
|
description: Действующая ссылка; возвращается только владельцу через /me/playlists
|
||||||
tracks:
|
tracks:
|
||||||
type: array
|
type: array
|
||||||
items: { $ref: '#/components/schemas/Track' }
|
items: { $ref: '#/components/schemas/Track' }
|
||||||
@@ -2896,6 +2972,16 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
required: [running, full, started_at, finished_at, found, to_process, processed, added, updated, moved, missing, errors, error]
|
required: [running, full, started_at, finished_at, found, to_process, processed, added, updated, moved, missing, errors, error]
|
||||||
properties:
|
properties:
|
||||||
|
phase:
|
||||||
|
type: string
|
||||||
|
enum: [walking, preparing, processing, finalizing]
|
||||||
|
description: Current scan stage; progress percentage is available only during processing.
|
||||||
|
walked_dirs:
|
||||||
|
type: integer
|
||||||
|
description: Directories visited so far, including the source root.
|
||||||
|
current_dir:
|
||||||
|
type: string
|
||||||
|
description: Current directory relative to the source root, using forward slashes.
|
||||||
running:
|
running:
|
||||||
type: boolean
|
type: boolean
|
||||||
full:
|
full:
|
||||||
@@ -2997,6 +3083,39 @@ components:
|
|||||||
next_cursor:
|
next_cursor:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
|
AnalysisError:
|
||||||
|
type: object
|
||||||
|
required: [track_id, title, source_id, source_name, rel_path, error]
|
||||||
|
properties:
|
||||||
|
track_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
source_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
nullable: true
|
||||||
|
source_name:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
rel_path:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: Path relative to the source root; primary file if available, otherwise a known file of this track.
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
AnalysisErrorPage:
|
||||||
|
type: object
|
||||||
|
required: [items, next_cursor]
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/AnalysisError"
|
||||||
|
next_cursor:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
HostDirList:
|
HostDirList:
|
||||||
type: object
|
type: object
|
||||||
required: [path, dirs]
|
required: [path, dirs]
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { components } from '@pipu/api-client';
|
import type { components } from '@pipu/api-client';
|
||||||
import { unwrap } from '@pipu/core';
|
import { playlistShareUrl, unwrap } from '@pipu/core';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Alert, FlatList, Modal, ScrollView, View } from 'react-native';
|
import { Alert, FlatList, Modal, ScrollView, Share, View } from 'react-native';
|
||||||
import { useSession, useUser } from '../lib/session';
|
import { useSession, useSessionCtx, useUser } from '../lib/session';
|
||||||
import { player } from '../player/controller';
|
import { player } from '../player/controller';
|
||||||
import { Button, ErrorBox, Field, H1, Muted, Screen, TrackRow, formatDuration, useErrorText } from '../ui/kit';
|
import { Button, ErrorBox, Field, H1, Muted, Screen, TrackRow, formatDuration, useErrorText } from '../ui/kit';
|
||||||
|
|
||||||
@@ -78,6 +78,7 @@ function Editor({ playlist, track, onClose, onSaved }: { playlist?: Playlist; tr
|
|||||||
|
|
||||||
function Detail({ id }: { id: string }) {
|
function Detail({ id }: { id: string }) {
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
|
const { server } = useSessionCtx();
|
||||||
const user = useUser();
|
const user = useUser();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -89,6 +90,15 @@ function Detail({ id }: { id: string }) {
|
|||||||
const key = ['m-playlists', user.id, id];
|
const key = ['m-playlists', user.id, id];
|
||||||
const query = useQuery({ queryKey: key, queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
|
const query = useQuery({ queryKey: key, queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
|
||||||
const p = query.data;
|
const p = query.data;
|
||||||
|
const sharing = useMutation({ mutationFn: async () => {
|
||||||
|
if (!server || !p) throw new Error('Server unavailable');
|
||||||
|
const token = unwrap(await session.client.POST('/api/v1/me/playlists/{id}/share', { params: { path: { id } } })).share_id;
|
||||||
|
cache.setQueryData<Playlist>(key, current => current ? { ...current, share_id: token } : current);
|
||||||
|
await Share.share({ title: p.name, message: playlistShareUrl(server, token) }, { dialogTitle: t('playlists.share') });
|
||||||
|
} });
|
||||||
|
const revoke = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}/share', { params: { path: { id } } })),
|
||||||
|
onSuccess: () => { cache.setQueryData<Playlist>(key, current => current ? { ...current, share_id: null } : current); } });
|
||||||
|
|
||||||
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body: { ...input(p!), track_ids: ids } })),
|
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body: { ...input(p!), track_ids: ids } })),
|
||||||
onSuccess: data => { cache.setQueryData(key, data); void cache.invalidateQueries({ queryKey: ['m-playlists'] }); },
|
onSuccess: data => { cache.setQueryData(key, data); void cache.invalidateQueries({ queryKey: ['m-playlists'] }); },
|
||||||
onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } });
|
onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } });
|
||||||
@@ -105,11 +115,14 @@ function Detail({ id }: { id: string }) {
|
|||||||
return <Screen><FlatList data={p?.tracks ?? []} keyExtractor={t => t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching}
|
return <Screen><FlatList data={p?.tracks ?? []} keyExtractor={t => t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching}
|
||||||
ListHeaderComponent={<View style={{ padding:16,gap:12 }}>
|
ListHeaderComponent={<View style={{ padding:16,gap:12 }}>
|
||||||
<Button title={t('playlists.title')} variant="ghost" onPress={() => router.back()} />
|
<Button title={t('playlists.title')} variant="ghost" onPress={() => router.back()} />
|
||||||
<ErrorBox>{query.error || save.error || deletion.error || playError ? errorText(query.error ?? save.error ?? deletion.error ?? playError) : null}</ErrorBox>
|
<ErrorBox>{query.error || save.error || deletion.error || playError || sharing.error || revoke.error ? errorText(query.error ?? save.error ?? deletion.error ?? playError ?? sharing.error ?? revoke.error) : null}</ErrorBox>
|
||||||
{query.isPending && <Muted>{t('playlists.loading')}</Muted>}
|
{query.isPending && <Muted>{t('playlists.loading')}</Muted>}
|
||||||
{p && <><H1>{p.name}</H1><Muted>{p.description}</Muted><Muted>{t('catalog.track_count',{count:p.track_count})} · {formatDuration(p.duration_ms)}</Muted>
|
{p && <><H1>{p.name}</H1><Muted>{p.description}</Muted><Muted>{t('catalog.track_count',{count:p.track_count})} · {formatDuration(p.duration_ms)}</Muted>
|
||||||
<Button title={t('collection.play_all')} disabled={!p.tracks.some(t => t.available)} onPress={() => play(false)} />
|
<Button title={t('collection.play_all')} disabled={!p.tracks.some(t => t.available)} onPress={() => play(false)} />
|
||||||
<Button title={t('collection.shuffle')} variant="secondary" disabled={!p.tracks.some(t => t.available)} onPress={() => play(true)} />
|
<Button title={t('collection.shuffle')} variant="secondary" disabled={!p.tracks.some(t => t.available)} onPress={() => play(true)} />
|
||||||
|
<Button title={t('playlists.share')} variant="secondary" busy={sharing.isPending} disabled={revoke.isPending} onPress={() => sharing.mutate()} />
|
||||||
|
<Muted>{t('playlists.share_hint')}</Muted>
|
||||||
|
{p.share_id && <Button title={t('playlists.revoke_share')} variant="ghost" busy={revoke.isPending} disabled={sharing.isPending} onPress={() => revoke.mutate()} />}
|
||||||
<Button title={t('playlists.edit')} variant="secondary" disabled={save.isPending || deletion.isPending} onPress={() => setEdit(true)} />
|
<Button title={t('playlists.edit')} variant="secondary" disabled={save.isPending || deletion.isPending} onPress={() => setEdit(true)} />
|
||||||
<Button title={t('playlists.manage')} variant="secondary" onPress={() => setManage(!manage)} />
|
<Button title={t('playlists.manage')} variant="secondary" onPress={() => setManage(!manage)} />
|
||||||
<Button title={t('playlists.delete')} variant="danger" disabled={save.isPending} busy={deletion.isPending} onPress={() => Alert.alert(t('playlists.delete'),t('playlists.delete_confirm',{name:p.name}),[
|
<Button title={t('playlists.delete')} variant="danger" disabled={save.isPending} busy={deletion.isPending} onPress={() => Alert.alert(t('playlists.delete'),t('playlists.delete_confirm',{name:p.name}),[
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { initialWaveState, step, WAVE_COLORS, waveScale, type WaveState } from '@pipu/core';
|
import { initialWaveState, step, wavePalette, waveScale, type WaveState } from '@pipu/core';
|
||||||
import { Canvas, createPicture, Picture } from '@shopify/react-native-skia';
|
import { Canvas, createPicture, Picture } from '@shopify/react-native-skia';
|
||||||
import { useFocusEffect } from 'expo-router';
|
import { useFocusEffect } from 'expo-router';
|
||||||
import { useCallback, useEffect, useState, type ReactNode } from 'react';
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||||
import { AppState, Pressable, View } from 'react-native';
|
import { AppState, Pressable, View } from 'react-native';
|
||||||
import Animated, { useAnimatedStyle, useDerivedValue, useFrameCallback, useReducedMotion, useSharedValue } from 'react-native-reanimated';
|
import Animated, { useAnimatedStyle, useDerivedValue, useFrameCallback, useReducedMotion, useSharedValue } from 'react-native-reanimated';
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ export function WaveOrb({ variant, playing, bpm = 100, energy = 0.6, colors, siz
|
|||||||
}, []),
|
}, []),
|
||||||
);
|
);
|
||||||
const [foreground, setForeground] = useState(AppState.currentState === 'active');
|
const [foreground, setForeground] = useState(AppState.currentState === 'active');
|
||||||
const palette = (colors?.length === 3 ? colors : WAVE_COLORS) as [string, string, string];
|
const palette = useMemo(() => wavePalette(colors), [colors]);
|
||||||
|
|
||||||
const props = useSharedValue({ variant, playing, bpm, energy, colors: palette, dark, reduced });
|
const props = useSharedValue({ variant, playing, bpm, energy, colors: palette, dark, reduced });
|
||||||
const state = useSharedValue<WaveState>(initialWaveState(size));
|
const state = useSharedValue<WaveState>(initialWaveState(size));
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
|
|||||||
for (const width of [320, 1280]) {
|
for (const width of [320, 1280]) {
|
||||||
test(`playlists at ${width}px`, async ({ page }, info) => {
|
test(`playlists at ${width}px`, async ({ page }, info) => {
|
||||||
await page.setViewportSize({ width, height: 900 });
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||||
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
await page.getByLabel('Логин', { exact: true }).fill('alice');
|
await page.getByLabel('Логин', { exact: true }).fill('alice');
|
||||||
@@ -30,10 +31,22 @@ for (const width of [320, 1280]) {
|
|||||||
await page.getByRole('link', { name: /Музыка для долгой дороги/ }).click();
|
await page.getByRole('link', { name: /Музыка для долгой дороги/ }).click();
|
||||||
await page.getByRole('button', { name: 'Управлять треками' }).click();
|
await page.getByRole('button', { name: 'Управлять треками' }).click();
|
||||||
await expect(page.getByRole('button', { name: /Убрать .* из плейлиста/ })).toBeVisible();
|
await expect(page.getByRole('button', { name: /Убрать .* из плейлиста/ })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click();
|
||||||
|
await expect(page.getByRole('status')).toHaveText('Ссылка на плейлист скопирована');
|
||||||
|
const sharedUrl = await page.evaluate(() => navigator.clipboard.readText());
|
||||||
|
expect(sharedUrl).toMatch(/^http:\/\/127\.0\.0\.1:4173\/shared\/playlist\/[a-f0-9-]+$/);
|
||||||
|
await expect(page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true })).toBeVisible();
|
||||||
|
|
||||||
await expect.poll(() => page.evaluate(() => {
|
await expect.poll(() => page.evaluate(() => {
|
||||||
const main = document.querySelector('main')!;
|
const main = document.querySelector('main')!;
|
||||||
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
|
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
|
||||||
})).toBeLessThanOrEqual(1);
|
})).toBeLessThanOrEqual(1);
|
||||||
await page.screenshot({ path: info.outputPath('playlist.png') });
|
await page.screenshot({ path: info.outputPath('playlist.png') });
|
||||||
|
await page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true }).click();
|
||||||
|
await expect(page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true })).toHaveCount(0);
|
||||||
|
await page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click();
|
||||||
|
await expect(page.getByRole('status')).toHaveText('Ссылка на плейлист скопирована');
|
||||||
|
expect(await page.evaluate(() => navigator.clipboard.readText())).not.toBe(sharedUrl);
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,7 @@ import { afterEach, describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
import { createAppRouter, safeRedirect } from './app/router';
|
import { createAppRouter, safeRedirect } from './app/router';
|
||||||
import { fakeApi, makeUser, type FakeUser } from './test/fakeApi';
|
import { albumTracks, fakeApi, makeUser, type FakeUser } from './test/fakeApi';
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
@@ -506,3 +506,55 @@ describe('recommendation preferences', () => {
|
|||||||
expect(api.calls.filter((c) => c.path === '/api/v1/me/onboarding').at(-1)?.body).toEqual({ artist_ids: [], genres: [], gender: null, age_group: null });
|
expect(api.calls.filter((c) => c.path === '/api/v1/me/onboarding').at(-1)?.body).toEqual({ artist_ids: [], genres: [], gender: null, age_group: null });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('playlist sharing', () => {
|
||||||
|
it('copies a stable read-only link and revokes it without exposing editing controls', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const { user, router, session, api } = await renderApp([alice], '/', { signedInAs: alice });
|
||||||
|
const created = await session.client.POST('/api/v1/me/playlists', { body: { name: 'Общий микс', description: 'Для друзей', track_ids: [albumTracks[0]!.id] } });
|
||||||
|
const playlist = created.data!;
|
||||||
|
await router.navigate({ to: '/playlist/$id', params: { id: playlist.id } });
|
||||||
|
await screen.findByRole('heading', { name: 'Общий микс' });
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent('Ссылка на плейлист скопирована');
|
||||||
|
const shareId = api.playlists.get(playlist.id)!.share_id!;
|
||||||
|
expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}/shared/playlist/${shareId}`);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Поделиться плейлистом' })).toBeEnabled());
|
||||||
|
expect(api.calls.filter(c => c.method === 'POST' && c.path.endsWith('/share'))).toHaveLength(2);
|
||||||
|
expect(api.playlists.get(playlist.id)?.share_id).toBe(shareId);
|
||||||
|
await router.navigate({ to: '/shared/playlist/$shareId', params: { shareId } });
|
||||||
|
await screen.findByRole('heading', { name: 'Общий микс' });
|
||||||
|
expect(screen.queryByRole('button', { name: 'Изменить плейлист' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Управлять треками' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Удалить плейлист' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Слушать всё' }));
|
||||||
|
await screen.findByRole('contentinfo', { name: 'Плеер' });
|
||||||
|
await router.navigate({ to: '/playlist/$id', params: { id: playlist.id } });
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Отключить доступ по ссылке' }));
|
||||||
|
await waitFor(() => expect(api.playlists.get(playlist.id)?.share_id).toBeNull());
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
|
||||||
|
await waitFor(() => expect(api.playlists.get(playlist.id)?.share_id).toBeTruthy());
|
||||||
|
expect(api.playlists.get(playlist.id)?.share_id).not.toBe(shareId);
|
||||||
|
await router.navigate({ to: '/shared/playlist/$shareId', params: { shareId } });
|
||||||
|
await screen.findByRole('alert');
|
||||||
|
expect(screen.queryByRole('heading', { name: 'Общий микс' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns to the shared playlist after login without starting playback', async () => {
|
||||||
|
const bob = makeUser('bob', 'user');
|
||||||
|
const shareId = '00000000-0000-4000-9001-000000000001';
|
||||||
|
const { api, user, router } = await renderApp([bob], `/shared/playlist/${shareId}`);
|
||||||
|
api.playlists.set('00000000-0000-4000-9000-000000000001', { id: '00000000-0000-4000-9000-000000000001', name: 'От друга', description: '', revision: 1, tracks: [albumTracks[0]!], track_count: 1, duration_ms: 200000, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', share_id: shareId });
|
||||||
|
await screen.findByRole('heading', { name: 'Вход' });
|
||||||
|
await user.type(screen.getByLabelText('Логин'), bob.username);
|
||||||
|
await user.type(screen.getByLabelText('Пароль'), bob.password);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Войти' }));
|
||||||
|
await screen.findByRole('heading', { name: 'От друга' });
|
||||||
|
expect(router.state.location.pathname).toBe(`/shared/playlist/${shareId}`);
|
||||||
|
expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Изменить плейлист' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -145,6 +145,7 @@ const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
|
|||||||
|
|
||||||
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
|
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
|
||||||
const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> });
|
const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> });
|
||||||
|
const sharedPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/shared/playlist/$shareId', component: () => <PlaylistPage key={sharedPlaylistRoute.useParams().shareId} id={sharedPlaylistRoute.useParams().shareId} shared /> });
|
||||||
const playlistsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/playlists', component: PlaylistsPage });
|
const playlistsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/playlists', component: PlaylistsPage });
|
||||||
const playlistRoute = createRoute({ getParentRoute: () => appRoute, path: '/playlist/$id', component: () => <PlaylistPage id={playlistRoute.useParams().id} /> });
|
const playlistRoute = createRoute({ getParentRoute: () => appRoute, path: '/playlist/$id', component: () => <PlaylistPage id={playlistRoute.useParams().id} /> });
|
||||||
|
|
||||||
@@ -280,6 +281,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
homeRoute,
|
homeRoute,
|
||||||
trackRoute,
|
trackRoute,
|
||||||
playlistsRoute,
|
playlistsRoute,
|
||||||
|
sharedPlaylistRoute,
|
||||||
playlistRoute,
|
playlistRoute,
|
||||||
settingsRoute,
|
settingsRoute,
|
||||||
searchRoute,
|
searchRoute,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { unwrap } from '@pipu/core';
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { memo, useCallback, useState } from 'react';
|
import { memo, useCallback, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useSession } from '../app/session';
|
import { useSession, useUser } from '../app/session';
|
||||||
import { useErrorText } from '../shared/lib/errors';
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui';
|
import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui';
|
||||||
import { playlistAddIcon } from '../shared/ui/icons';
|
import { playlistAddIcon } from '../shared/ui/icons';
|
||||||
@@ -18,10 +18,13 @@ export function usePlaylists(enabled = true) {
|
|||||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePlaylist(id: string) {
|
export function usePlaylist(id: string, shared = false) {
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
return useQuery({ queryKey: ['playlists', id],
|
const user = useUser();
|
||||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
|
return useQuery({ queryKey: shared ? ['shared-playlists', id, user.id] : ['playlists', id], staleTime: 0,
|
||||||
|
queryFn: async () => shared
|
||||||
|
? unwrap(await session.client.GET('/api/v1/shared/playlists/{shareId}', { params: { path: { shareId: id } } }))
|
||||||
|
: unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function playlistInput(p: Playlist): PlaylistInput & { revision: number } {
|
export function playlistInput(p: Playlist): PlaylistInput & { revision: number } {
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ const ICONS: Record<string, string> = {
|
|||||||
rainy: waveRainyIcon,
|
rainy: waveRainyIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
const WAVE_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
|
|
||||||
|
|
||||||
type Tr = (key: string) => string;
|
type Tr = (key: string) => string;
|
||||||
|
|
||||||
/** Главная: для нового пользователя без истории — сначала онбординг вкуса, затем волна. */
|
/** Главная: для нового пользователя без истории — сначала онбординг вкуса, затем волна. */
|
||||||
@@ -97,8 +95,6 @@ function WaveHome() {
|
|||||||
const waveOn = st.wave !== null;
|
const waveOn = st.wave !== null;
|
||||||
const playing = waveOn && st.playing;
|
const playing = waveOn && st.playing;
|
||||||
const track = waveOn ? st.track : null;
|
const track = waveOn ? st.track : null;
|
||||||
const colors: [string, string, string] =
|
|
||||||
track?.album.cover_colors.length === 3 ? (track.album.cover_colors as [string, string, string]) : WAVE_COLORS;
|
|
||||||
|
|
||||||
const run = (fn: () => Promise<void> | void) => {
|
const run = (fn: () => Promise<void> | void) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -129,7 +125,7 @@ function WaveHome() {
|
|||||||
readAudio={player.readAudio}
|
readAudio={player.readAudio}
|
||||||
bpm={track?.features?.bpm ?? undefined}
|
bpm={track?.features?.bpm ?? undefined}
|
||||||
energy={track?.features?.energy ?? undefined}
|
energy={track?.features?.energy ?? undefined}
|
||||||
colors={colors}
|
colors={track?.album.cover_colors}
|
||||||
theme={document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'}
|
theme={document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'}
|
||||||
size={320}
|
size={320}
|
||||||
label={t(playing ? 'wave.pause' : 'wave.start')}
|
label={t(playing ? 'wave.pause' : 'wave.start')}
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import { PlaylistEditor, playlistInput, usePlaylist, usePlaylists, type Playlist
|
|||||||
import type { components } from '@pipu/api-client';
|
import type { components } from '@pipu/api-client';
|
||||||
import { usePlayer } from '../player/context';
|
import { usePlayer } from '../player/context';
|
||||||
import { useErrorText } from '../shared/lib/errors';
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
|
import { sharePlaylist } from '../shared/lib/share';
|
||||||
import { formatDuration } from '../shared/lib/format';
|
import { formatDuration } from '../shared/lib/format';
|
||||||
import { Button, Dialog, EmptyState, Field, FormError, Icon, PageSkeleton } from '../shared/ui/ui';
|
import { Button, Dialog, EmptyState, Field, FormError, Icon, PageSkeleton } from '../shared/ui/ui';
|
||||||
import { playIcon, playlistAddIcon, searchIcon, shuffleIcon } from '../shared/ui/icons';
|
import { playIcon, playlistAddIcon, searchIcon, shuffleIcon, shareIcon } from '../shared/ui/icons';
|
||||||
import c from './catalog.module.css';
|
import c from './catalog.module.css';
|
||||||
import s from './page.module.css';
|
import s from './page.module.css';
|
||||||
import p from './playlists.module.css';
|
import p from './playlists.module.css';
|
||||||
@@ -42,19 +43,19 @@ export function PlaylistsPage() {
|
|||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlaylistPage({ id }: { id: string }) {
|
export function PlaylistPage({ id, shared = false }: { id: string; shared?: boolean }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const query = usePlaylist(id);
|
const query = usePlaylist(id, shared);
|
||||||
const errorText = useErrorText();
|
const errorText = useErrorText();
|
||||||
return <div className={s.page}>
|
return <div className={s.page}>
|
||||||
<Link to="/collection/playlists">← {t('playlists.title')}</Link>
|
<Link to="/collection/playlists">← {t('playlists.title')}</Link>
|
||||||
<FormError>{query.error ? errorText(query.error) : null}</FormError>
|
<FormError>{query.error ? errorText(query.error) : null}</FormError>
|
||||||
{query.isPending && <PageSkeleton />}
|
{query.isPending && <PageSkeleton />}
|
||||||
{query.data && <PlaylistDetail key={id} playlist={query.data} />}
|
{query.data && !query.isError && <PlaylistDetail key={id} playlist={query.data} readOnly={shared} shareId={shared ? id : undefined} />}
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PlaylistDetail({ playlist }: { playlist: Playlist }) {
|
function PlaylistDetail({ playlist, readOnly = false, shareId }: { playlist: Playlist; readOnly?: boolean; shareId?: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
const cache = useQueryClient();
|
const cache = useQueryClient();
|
||||||
@@ -64,6 +65,21 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
|
|||||||
const [edit, setEdit] = useState(false);
|
const [edit, setEdit] = useState(false);
|
||||||
const [remove, setRemove] = useState(false);
|
const [remove, setRemove] = useState(false);
|
||||||
const [manage, setManage] = useState(false);
|
const [manage, setManage] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const sharing = useMutation({ mutationFn: async () => {
|
||||||
|
setCopied(false);
|
||||||
|
let token = shareId ?? playlist.share_id;
|
||||||
|
if (!readOnly) {
|
||||||
|
token = unwrap(await session.client.POST('/api/v1/me/playlists/{id}/share', { params: { path: { id: playlist.id } } })).share_id;
|
||||||
|
cache.setQueryData<Playlist>(['playlists', playlist.id], current => current ? { ...current, share_id: token } : current);
|
||||||
|
}
|
||||||
|
if (!token) throw new Error('Share link unavailable');
|
||||||
|
const result = await sharePlaylist(token, playlist.name);
|
||||||
|
if (result === 'copied') setCopied(true);
|
||||||
|
} });
|
||||||
|
const revoke = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}/share', { params: { path: { id: playlist.id } } })),
|
||||||
|
onSuccess: () => { setCopied(false); cache.setQueryData<Playlist>(['playlists', playlist.id], current => current ? { ...current, share_id: null } : current); cache.removeQueries({ queryKey: ['shared-playlists', playlist.share_id] }); } });
|
||||||
|
|
||||||
const context = { type: 'collection' as const, id: playlist.id, label: playlist.name };
|
const context = { type: 'collection' as const, id: playlist.id, label: playlist.name };
|
||||||
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', {
|
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', {
|
||||||
params: { path: { id: playlist.id } }, body: { ...playlistInput(playlist), track_ids: ids },
|
params: { path: { id: playlist.id } }, body: { ...playlistInput(playlist), track_ids: ids },
|
||||||
@@ -84,7 +100,7 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
|
|||||||
return <>
|
return <>
|
||||||
<header className={c.colHero}>
|
<header className={c.colHero}>
|
||||||
<Cover album={playlist.tracks[0]?.album ?? playlist} size={144} />
|
<Cover album={playlist.tracks[0]?.album ?? playlist} size={144} />
|
||||||
<div className={c.heroText}><span className={c.kicker}>{t('playlists.private')}</span><h1 className={c.colTitle}>{playlist.name}</h1>
|
<div className={c.heroText}><span className={c.kicker}>{t(readOnly || playlist.share_id ? 'playlists.shared' : 'playlists.private')}</span><h1 className={c.colTitle}>{playlist.name}</h1>
|
||||||
<p className={p.description}>{playlist.description}</p>
|
<p className={p.description}>{playlist.description}</p>
|
||||||
<span>{t('catalog.track_count', { count: playlist.track_count })} · {formatDuration(playlist.duration_ms)}</span>
|
<span>{t('catalog.track_count', { count: playlist.track_count })} · {formatDuration(playlist.duration_ms)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,10 +108,17 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
|
|||||||
<div className={s.toolbar}>
|
<div className={s.toolbar}>
|
||||||
<Button variant="primary" icon={playIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(false)}>{t('collection.play_all')}</Button>
|
<Button variant="primary" icon={playIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(false)}>{t('collection.play_all')}</Button>
|
||||||
<Button icon={shuffleIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(true)}>{t('collection.shuffle')}</Button>
|
<Button icon={shuffleIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(true)}>{t('collection.shuffle')}</Button>
|
||||||
|
<Button icon={shareIcon} busy={sharing.isPending} disabled={revoke.isPending} onClick={() => sharing.mutate()}>{t('playlists.share')}</Button>
|
||||||
|
{!readOnly && <>
|
||||||
<Button disabled={save.isPending} onClick={() => setEdit(true)}>{t('playlists.edit')}</Button>
|
<Button disabled={save.isPending} onClick={() => setEdit(true)}>{t('playlists.edit')}</Button>
|
||||||
<Button aria-pressed={manage} onClick={() => setManage(!manage)}>{t('playlists.manage')}</Button>
|
<Button aria-pressed={manage} onClick={() => setManage(!manage)}>{t('playlists.manage')}</Button>
|
||||||
<Button variant="ghost" disabled={save.isPending} onClick={() => setRemove(true)}>{t('playlists.delete')}</Button>
|
<Button variant="ghost" disabled={save.isPending} onClick={() => setRemove(true)}>{t('playlists.delete')}</Button>
|
||||||
|
{playlist.share_id && <Button variant="ghost" busy={revoke.isPending} disabled={sharing.isPending} onClick={() => revoke.mutate()}>{t('playlists.revoke_share')}</Button>}
|
||||||
|
</>}
|
||||||
</div>
|
</div>
|
||||||
|
{!readOnly && <p>{t('playlists.share_hint')}</p>}
|
||||||
|
{copied && <p role="status">{t('playlists.link_copied')}</p>}
|
||||||
|
<FormError>{sharing.error ? t('player.share_error') : revoke.error ? errorText(revoke.error) : null}</FormError>
|
||||||
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||||
{!playlist.tracks.length && (
|
{!playlist.tracks.length && (
|
||||||
<EmptyState icon={playlistAddIcon} action={<Link to="/search" search={{ q: '' }}>{t('playlists.find_tracks')}</Link>}>
|
<EmptyState icon={playlistAddIcon} action={<Link to="/search" search={{ q: '' }}>{t('playlists.find_tracks')}</Link>}>
|
||||||
@@ -103,13 +126,13 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
|
|||||||
</EmptyState>
|
</EmptyState>
|
||||||
)}
|
)}
|
||||||
<TrackList tracks={playlist.tracks} showAlbum context={context} lead={(_, i) => String(i + 1)}
|
<TrackList tracks={playlist.tracks} showAlbum context={context} lead={(_, i) => String(i + 1)}
|
||||||
actions={manage ? (track, i) => <span className={p.actions}>
|
actions={!readOnly && manage ? (track, i) => <span className={p.actions}>
|
||||||
<button type="button" disabled={i === 0 || save.isPending} aria-label={t('playlists.move_up', { name: track.title })} onClick={() => move(i, -1)}>↑</button>
|
<button type="button" disabled={i === 0 || save.isPending} aria-label={t('playlists.move_up', { name: track.title })} onClick={() => move(i, -1)}>↑</button>
|
||||||
<button type="button" disabled={i === playlist.tracks.length - 1 || save.isPending} aria-label={t('playlists.move_down', { name: track.title })} onClick={() => move(i, 1)}>↓</button>
|
<button type="button" disabled={i === playlist.tracks.length - 1 || save.isPending} aria-label={t('playlists.move_down', { name: track.title })} onClick={() => move(i, 1)}>↓</button>
|
||||||
<button type="button" disabled={save.isPending} aria-label={t('playlists.remove_track', { name: track.title })} onClick={() => save.mutate(playlist.tracks.filter(x => x.id !== track.id).map(x => x.id))}>×</button>
|
<button type="button" disabled={save.isPending} aria-label={t('playlists.remove_track', { name: track.title })} onClick={() => save.mutate(playlist.tracks.filter(x => x.id !== track.id).map(x => x.id))}>×</button>
|
||||||
</span> : undefined} />
|
</span> : undefined} />
|
||||||
{edit && <PlaylistEditor playlist={playlist} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}
|
{!readOnly && edit && <PlaylistEditor playlist={playlist} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}
|
||||||
{remove && (
|
{!readOnly && remove && (
|
||||||
<Dialog
|
<Dialog
|
||||||
title={t('playlists.delete')}
|
title={t('playlists.delete')}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { AuthSession } from '@pipu/core';
|
||||||
|
import { createI18n } from '@pipu/i18n';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { cleanup, render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { I18nextProvider } from 'react-i18next';
|
||||||
|
import { afterEach, expect, it, vi } from 'vitest';
|
||||||
|
import { SessionProvider } from '../../app/session';
|
||||||
|
import { AnalysisErrorsDialog } from './AnalysisErrorsDialog';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
async function fixture(mode: 'pages' | 'empty' | 'error' = 'pages') {
|
||||||
|
const cursors: (string | null)[] = [];
|
||||||
|
const session = new AuthSession({ baseUrl: 'http://localhost', clientType: 'web', fetch: async (input, init) => {
|
||||||
|
const req = new Request(input, init);
|
||||||
|
const cursor = new URL(req.url).searchParams.get('cursor');
|
||||||
|
cursors.push(cursor);
|
||||||
|
if (mode === 'error') return new Response(JSON.stringify({ code: 'server.error' }), { status: 500 });
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
items: mode === 'empty' ? [] : cursor ? [{ track_id: 'second', title: 'Без файла', source_id: null, source_name: null, rel_path: null, error: '' }]
|
||||||
|
: [{ track_id: 'first', title: 'Короткий трек', source_id: 'source', source_name: 'Base', rel_path: 'Исполнитель/Альбом/track.mp3', error: 'audio is shorter than 1 s' }],
|
||||||
|
next_cursor: mode === 'pages' && !cursor ? 'next-page' : null,
|
||||||
|
}), { headers: { 'Content-Type': 'application/json' } });
|
||||||
|
} });
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
const i18n = await createI18n('ru');
|
||||||
|
const close = vi.fn();
|
||||||
|
render(<I18nextProvider i18n={i18n}><SessionProvider session={session}><QueryClientProvider client={client}>
|
||||||
|
<AnalysisErrorsDialog onClose={close} />
|
||||||
|
</QueryClientProvider></SessionProvider></I18nextProvider>);
|
||||||
|
return { close, cursors, user: userEvent.setup() };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('shows storage, path and error, loads the next page and closes with Escape', async () => {
|
||||||
|
const { user, cursors, close } = await fixture();
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Ошибки аудиоанализа' })).toBeVisible();
|
||||||
|
expect(await screen.findByText('Исполнитель/Альбом/track.mp3')).toBeVisible();
|
||||||
|
expect(screen.getByText('Хранилище: Base')).toBeVisible();
|
||||||
|
expect(screen.getByText('audio is shorter than 1 s')).toBeVisible();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Показать ещё' }));
|
||||||
|
expect(await screen.findByText('В библиотеке больше нет файла этого трека.')).toBeVisible();
|
||||||
|
expect(screen.getByText('Короткий трек')).toBeVisible();
|
||||||
|
expect(cursors).toEqual([null, 'next-page']);
|
||||||
|
expect(screen.queryByRole('button', { name: 'Показать ещё' })).not.toBeInTheDocument();
|
||||||
|
await user.keyboard('{Escape}');
|
||||||
|
expect(close).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an empty state after failed jobs have been retried', async () => {
|
||||||
|
await fixture('empty');
|
||||||
|
expect(await screen.findByText('Неудавшихся задач анализа нет.')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a request failure without claiming the list is empty', async () => {
|
||||||
|
await fixture('error');
|
||||||
|
expect(await screen.findByRole('alert')).toBeVisible();
|
||||||
|
expect(screen.queryByText('Неудавшихся задач анализа нет.')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../../app/session';
|
||||||
|
import { useErrorText } from '../../shared/lib/errors';
|
||||||
|
import { Button, Dialog, FormError } from '../../shared/ui/ui';
|
||||||
|
import s from './library.module.css';
|
||||||
|
|
||||||
|
export function AnalysisErrorsDialog({ onClose }: { onClose: () => void }) {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const errors = useInfiniteQuery({
|
||||||
|
queryKey: ['analysis-errors'],
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
queryFn: async ({ pageParam }) => unwrap(await session.client.GET('/api/v1/admin/library/analysis/errors', {
|
||||||
|
params: { query: { limit: 50, cursor: pageParam } },
|
||||||
|
})),
|
||||||
|
getNextPageParam: (page) => page.next_cursor ?? undefined,
|
||||||
|
});
|
||||||
|
const items = errors.data?.pages.flatMap((page) => page.items) ?? [];
|
||||||
|
|
||||||
|
return <Dialog title={t('library.analysis_errors_title')} onClose={onClose}
|
||||||
|
footer={<Button onClick={onClose}>{t('common:form.close')}</Button>}>
|
||||||
|
<p>{t('library.analysis_errors_hint')}</p>
|
||||||
|
{errors.isPending && <p role="status">{t('common:loading')}</p>}
|
||||||
|
<FormError>{errors.error ? errorText(errors.error) : null}</FormError>
|
||||||
|
{errors.isSuccess && items.length === 0 && <p>{t('library.analysis_errors_empty')}</p>}
|
||||||
|
{items.length > 0 && <ul className={s.analysisErrors}>
|
||||||
|
{items.map((item) => <li key={item.track_id}>
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
{item.source_name && <span className={s.scanSummary}>{t('library.analysis_error_source', { name: item.source_name })}</span>}
|
||||||
|
<code className={s.analysisPath}>{item.rel_path ?? t('library.analysis_error_no_file')}</code>
|
||||||
|
<span className={s.scanError}>{item.error || t('library.analysis_error_unknown')}</span>
|
||||||
|
</li>)}
|
||||||
|
</ul>}
|
||||||
|
{errors.hasNextPage && <Button busy={errors.isFetchingNextPage} onClick={() => void errors.fetchNextPage()}>{t('library.analysis_errors_more')}</Button>}
|
||||||
|
</Dialog>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { AuthSession } from '@pipu/core';
|
||||||
|
import { createI18n } from '@pipu/i18n';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { cleanup, render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { I18nextProvider } from 'react-i18next';
|
||||||
|
import { afterEach, expect, it } from 'vitest';
|
||||||
|
import { SessionProvider } from '../../app/session';
|
||||||
|
import { GeniusPanel } from './GeniusPanel';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const initial = {
|
||||||
|
configured: true, paused: false, running: true,
|
||||||
|
total: 100, pending: 80, matched: 17, lyrics: 15, annotations: 5, images: 17,
|
||||||
|
unmatched: 2, errors: 1, last_error: '', cooldown_until: '2000-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fixture(overrides: Partial<typeof initial> = {}) {
|
||||||
|
let data = { ...initial, ...overrides };
|
||||||
|
const actions: string[] = [];
|
||||||
|
const session = new AuthSession({ baseUrl: 'http://localhost', clientType: 'web', fetch: async (url, init) => {
|
||||||
|
const request = new Request(url, init);
|
||||||
|
if (request.method === 'POST') {
|
||||||
|
const { action } = await request.json() as { action: string };
|
||||||
|
actions.push(action);
|
||||||
|
data = { ...data, paused: action === 'pause' };
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
|
||||||
|
} });
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
|
||||||
|
client.setQueryData(['genius-stats'], data);
|
||||||
|
const i18n = await createI18n('ru');
|
||||||
|
render(<I18nextProvider i18n={i18n}><SessionProvider session={session}><QueryClientProvider client={client}>
|
||||||
|
<GeniusPanel />
|
||||||
|
</QueryClientProvider></SessionProvider></I18nextProvider>);
|
||||||
|
return { actions, user: userEvent.setup() };
|
||||||
|
}
|
||||||
|
|
||||||
|
it.each<[Partial<typeof initial>, string]>([
|
||||||
|
[{}, 'Получение данных из Genius…'],
|
||||||
|
[{ running: false }, 'Очередь ожидает обработки'],
|
||||||
|
[{ paused: true }, 'Обработка приостановлена'],
|
||||||
|
[{ cooldown_until: '2099-01-01T00:00:00Z' }, 'Ожидание после ограничения Genius'],
|
||||||
|
[{ running: false, pending: 0 }, 'Ожидание новых треков'],
|
||||||
|
])('shows queue state and progress for %j', async (overrides, status) => {
|
||||||
|
await fixture(overrides);
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent(status);
|
||||||
|
const progress = screen.getByRole('progressbar', { name: 'Прогресс обработки Genius' });
|
||||||
|
expect(progress).toHaveAttribute('max', '100');
|
||||||
|
expect(progress).toHaveAttribute('value', String(100 - (overrides.pending ?? 80)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pauses and resumes the existing queue without requesting a retry', async () => {
|
||||||
|
const { user, actions } = await fixture();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Приостановить обработку' }));
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Продолжить обработку' }));
|
||||||
|
expect(actions).toEqual(['pause', 'start']);
|
||||||
|
expect(screen.getByRole('progressbar')).toHaveAttribute('value', '20');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('explains the retry scope and sends the retry action', async () => {
|
||||||
|
const { user, actions } = await fixture();
|
||||||
|
expect(screen.getByText(/Повтор добавит в очередь только/)).toBeVisible();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Добавить проблемные треки в очередь' }));
|
||||||
|
expect(actions).toEqual(['retry']);
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@ import s from '../page.module.css';
|
|||||||
import g from '../genius.module.css';
|
import g from '../genius.module.css';
|
||||||
|
|
||||||
export function GeniusPanel() {
|
export function GeniusPanel() {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
const queries = useQueryClient();
|
const queries = useQueryClient();
|
||||||
const errorText = useErrorText();
|
const errorText = useErrorText();
|
||||||
@@ -24,16 +24,23 @@ export function GeniusPanel() {
|
|||||||
});
|
});
|
||||||
const data = stats.data;
|
const data = stats.data;
|
||||||
const cooling = data && Date.parse(data.cooldown_until) > Date.now();
|
const cooling = data && Date.parse(data.cooldown_until) > Date.now();
|
||||||
const state = !data ? t('genius.loading') : !data.configured ? t('genius.disabled') : data.paused ? t('genius.paused') : cooling ? t('genius.cooldown') : data.running ? t('genius.running') : t('genius.waiting');
|
const state = !data ? t('genius.loading') : !data.configured ? t('genius.disabled') : data.paused ? t('genius.paused') : cooling ? t('genius.cooldown') : data.running ? t('genius.running') : data.pending > 0 ? t('genius.queued') : t('genius.waiting');
|
||||||
|
const processed = data ? Math.max(0, data.total - data.pending) : 0;
|
||||||
|
const formatNumber = (value: number) => value.toLocaleString(i18n.language);
|
||||||
return <section className={s.section} aria-label="Genius">
|
return <section className={s.section} aria-label="Genius">
|
||||||
<div className={s.head}><h2>Genius</h2><span role="status">{state}</span></div>
|
<div className={s.head}><h2>Genius</h2><span role="status">{state}</span></div>
|
||||||
<p className={s.subtitle}>{t('genius.description')}</p>
|
<p className={s.subtitle}>{t('genius.description')}</p>
|
||||||
<FormError>{stats.error ? errorText(stats.error) : control.error ? errorText(control.error) : null}</FormError>
|
<FormError>{stats.error ? errorText(stats.error) : control.error ? errorText(control.error) : null}</FormError>
|
||||||
{data && <>
|
{data && <>
|
||||||
{!data.configured && <p>{t('genius.configure')}</p>}
|
{!data.configured && <p>{t('genius.configure')}</p>}
|
||||||
|
{data.total > 0 && <div className={g.progress}>
|
||||||
|
<span>{t('genius.progress', { processed: formatNumber(processed), total: formatNumber(data.total) })}</span>
|
||||||
|
<progress aria-label={t('genius.progress_label')} value={processed} max={data.total} />
|
||||||
|
<p className={s.subtitle}>{t('genius.progress_hint')}</p>
|
||||||
|
</div>}
|
||||||
<dl className={g.stats}>
|
<dl className={g.stats}>
|
||||||
{(['total', 'pending', 'matched', 'lyrics', 'annotations', 'images', 'unmatched', 'errors'] as const).map((key) =>
|
{(['total', 'pending', 'matched', 'lyrics', 'annotations', 'images', 'unmatched', 'errors'] as const).map((key) =>
|
||||||
<div key={key}><dt>{t(`genius.${key}`)}</dt><dd>{data[key].toLocaleString()}</dd></div>)}
|
<div key={key}><dt>{t(`genius.${key}`)}</dt><dd>{formatNumber(data[key])}</dd></div>)}
|
||||||
</dl>
|
</dl>
|
||||||
{data.last_error && <p role="alert">{t('genius.last_error')}: {data.last_error === 'http_401' ? t('genius.token_error') : data.last_error === 'http_403' ? t('genius.access_error') : data.last_error === 'http_429' ? t('genius.cooldown') : data.last_error === 'lyrics_unavailable' ? t('genius.no_lyrics') : t('genius.request_error')}</p>}
|
{data.last_error && <p role="alert">{t('genius.last_error')}: {data.last_error === 'http_401' ? t('genius.token_error') : data.last_error === 'http_403' ? t('genius.access_error') : data.last_error === 'http_429' ? t('genius.cooldown') : data.last_error === 'lyrics_unavailable' ? t('genius.no_lyrics') : t('genius.request_error')}</p>}
|
||||||
{cooling && <p>{t('genius.resume_at')}: {new Date(data.cooldown_until).toLocaleString()}</p>}
|
{cooling && <p>{t('genius.resume_at')}: {new Date(data.cooldown_until).toLocaleString()}</p>}
|
||||||
@@ -41,6 +48,8 @@ export function GeniusPanel() {
|
|||||||
<Button disabled={!data.configured || control.isPending} onClick={() => control.mutate(data.paused ? 'start' : 'pause')}>{t(data.paused ? 'genius.start' : 'genius.pause')}</Button>
|
<Button disabled={!data.configured || control.isPending} onClick={() => control.mutate(data.paused ? 'start' : 'pause')}>{t(data.paused ? 'genius.start' : 'genius.pause')}</Button>
|
||||||
<Button disabled={!data.configured || control.isPending || data.errors + data.unmatched === 0} onClick={() => control.mutate('retry')}>{t('genius.retry')}</Button>
|
<Button disabled={!data.configured || control.isPending || data.errors + data.unmatched === 0} onClick={() => control.mutate('retry')}>{t('genius.retry')}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<p className={s.subtitle}>{t('genius.controls_hint')}</p>
|
||||||
|
<p className={s.subtitle}>{t('genius.retry_hint')}</p>
|
||||||
</>}
|
</>}
|
||||||
</section>;
|
</section>;
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { unwrap } from '@pipu/core';
|
import { unwrap } from '@pipu/core';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useId } from 'react';
|
import { useId, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useSession, useUser } from '../../app/session';
|
import { useSession, useUser } from '../../app/session';
|
||||||
@@ -10,6 +10,7 @@ import { Button, FormError } from '../../shared/ui/ui';
|
|||||||
import s from '../page.module.css';
|
import s from '../page.module.css';
|
||||||
import l from './library.module.css';
|
import l from './library.module.css';
|
||||||
import { ScanLine, StatusBadge } from './ScanStatus';
|
import { ScanLine, StatusBadge } from './ScanStatus';
|
||||||
|
import { AnalysisErrorsDialog } from './AnalysisErrorsDialog';
|
||||||
|
|
||||||
/** Состояние библиотеки: счётчики, хранилища и сканы (admin, moderator), ошибки чтения (admin). */
|
/** Состояние библиотеки: счётчики, хранилища и сканы (admin, moderator), ошибки чтения (admin). */
|
||||||
export function LibraryPage() {
|
export function LibraryPage() {
|
||||||
@@ -18,6 +19,7 @@ export function LibraryPage() {
|
|||||||
const user = useUser();
|
const user = useUser();
|
||||||
const errorText = useErrorText();
|
const errorText = useErrorText();
|
||||||
const admin = user.role === 'admin';
|
const admin = user.role === 'admin';
|
||||||
|
const [showAnalysisErrors, setShowAnalysisErrors] = useState(false);
|
||||||
|
|
||||||
const status = useQuery({
|
const status = useQuery({
|
||||||
queryKey: ['library-status'],
|
queryKey: ['library-status'],
|
||||||
@@ -27,6 +29,7 @@ export function LibraryPage() {
|
|||||||
const errors = useQuery({
|
const errors = useQuery({
|
||||||
queryKey: ['library-errors'],
|
queryKey: ['library-errors'],
|
||||||
enabled: admin,
|
enabled: admin,
|
||||||
|
refetchInterval: status.data?.sources.some((source) => source.scan?.running) ? 2000 : 15000,
|
||||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/library/errors', { params: { query: { limit: 50 } } })),
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/library/errors', { params: { query: { limit: 50 } } })),
|
||||||
});
|
});
|
||||||
const reindex = useMutation({
|
const reindex = useMutation({
|
||||||
@@ -35,7 +38,10 @@ export function LibraryPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const retry = useMutation({
|
const retry = useMutation({
|
||||||
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/library/analysis/retry')),
|
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/library/analysis/retry')),
|
||||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['library-status'] }),
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['library-status'] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['analysis-errors'] });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const d = status.data;
|
const d = status.data;
|
||||||
const tiles = d
|
const tiles = d
|
||||||
@@ -89,13 +95,17 @@ export function LibraryPage() {
|
|||||||
{d.analysis.top_error && <span className={l.errorDetail}>{d.analysis.top_error}</span>}
|
{d.analysis.top_error && <span className={l.errorDetail}>{d.analysis.top_error}</span>}
|
||||||
</span>
|
</span>
|
||||||
{admin && (
|
{admin && (
|
||||||
<Button variant="secondary" icon={refreshIcon} busy={retry.isPending} onClick={() => retry.mutate()}>
|
<div className={l.analysisActions}>
|
||||||
{t('library.analysis_retry')}
|
<Button onClick={() => setShowAnalysisErrors(true)}>{t('library.analysis_show_errors')}</Button>
|
||||||
</Button>
|
<Button variant="secondary" icon={refreshIcon} busy={retry.isPending} onClick={() => retry.mutate()}>
|
||||||
|
{t('library.analysis_retry')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{d.analysis.analyzer_ver && <span className={l.scanSummary}>{t('library.analyzer', { ver: d.analysis.analyzer_ver })}</span>}
|
{d.analysis.analyzer_ver && <span className={l.scanSummary}>{t('library.analyzer', { ver: d.analysis.analyzer_ver })}</span>}
|
||||||
|
{showAnalysisErrors && <AnalysisErrorsDialog onClose={() => setShowAnalysisErrors(false)} />}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{d && (
|
{d && (
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { createI18n } from '@pipu/i18n';
|
||||||
|
import { cleanup, render, screen } from '@testing-library/react';
|
||||||
|
import { I18nextProvider } from 'react-i18next';
|
||||||
|
import { afterEach, expect, it } from 'vitest';
|
||||||
|
import { ScanLine } from './ScanStatus';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
const scan: components['schemas']['ScanProgress'] = {
|
||||||
|
running: true, full: false, started_at: '2026-10-22T12:00:00Z', finished_at: null,
|
||||||
|
found: 12, to_process: 0, processed: 0, added: 0, updated: 0, moved: 0, missing: 0, errors: 0, error: null,
|
||||||
|
phase: 'walking', walked_dirs: 4, current_dir: 'Artist/Album',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fixture(overrides: Partial<typeof scan> = {}) {
|
||||||
|
const i18n = await createI18n('ru');
|
||||||
|
render(<I18nextProvider i18n={i18n}><ScanLine scan={{ ...scan, ...overrides }} lastScanAt={null} /></I18nextProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('shows changing folder and file counts during the walk without a misleading percentage', async () => {
|
||||||
|
await fixture();
|
||||||
|
expect(screen.getByText(/Проверено папок: 4 · найдено аудиофайлов: 12/)).toBeVisible();
|
||||||
|
expect(screen.getByText('Текущая папка: Artist/Album')).toBeVisible();
|
||||||
|
expect(screen.getByRole('progressbar')).not.toHaveAttribute('aria-valuenow');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a percentage only once file processing has a known total', async () => {
|
||||||
|
await fixture({ phase: 'processing', to_process: 12, processed: 3 });
|
||||||
|
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '25');
|
||||||
|
expect(screen.queryByText(/Текущая папка/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([['preparing', 'Сопоставление файлов с библиотекой…'], ['finalizing', 'Завершение сканирования…']] as const)('labels the %s stage', async (phase, label) => {
|
||||||
|
await fixture({ phase });
|
||||||
|
expect(screen.getByText(label)).toBeVisible();
|
||||||
|
expect(screen.getByRole('progressbar')).not.toHaveAttribute('aria-valuenow');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports servers without the new progress fields', async () => {
|
||||||
|
await fixture({ phase: undefined, walked_dirs: undefined, current_dir: undefined, found: 0 });
|
||||||
|
expect(screen.getByText('Обход папок…')).toBeVisible();
|
||||||
|
});
|
||||||
@@ -19,14 +19,22 @@ export function ScanLine({ scan, lastScanAt }: { scan: ScanProgress | null; last
|
|||||||
const { t, i18n } = useTranslation('admin');
|
const { t, i18n } = useTranslation('admin');
|
||||||
const id = useId();
|
const id = useId();
|
||||||
if (scan?.running) {
|
if (scan?.running) {
|
||||||
|
const phase = scan.phase ?? (scan.found === 0 ? 'walking' : scan.to_process > 0 ? 'processing' : 'preparing');
|
||||||
|
const determinate = phase === 'processing' && scan.to_process > 0;
|
||||||
const pct = scan.to_process > 0 ? Math.round((scan.processed / scan.to_process) * 100) : 0;
|
const pct = scan.to_process > 0 ? Math.round((scan.processed / scan.to_process) * 100) : 0;
|
||||||
|
const state = phase === 'walking'
|
||||||
|
? scan.walked_dirs === undefined ? t('sources.scan_walking') : t('sources.scan_walk_progress', { dirs: scan.walked_dirs.toLocaleString(i18n.language), found: scan.found.toLocaleString(i18n.language) })
|
||||||
|
: phase === 'preparing' ? t('sources.scan_preparing')
|
||||||
|
: phase === 'finalizing' ? t('sources.scan_finalizing')
|
||||||
|
: t('sources.scanning', { processed: scan.processed, total: scan.to_process });
|
||||||
return (
|
return (
|
||||||
<div className={s.scan}>
|
<div className={s.scan}>
|
||||||
<div className={s.scanText} id={id}>
|
<div className={s.scanText} id={id}>
|
||||||
{scan.found === 0 ? t('sources.scan_walking') : t('sources.scanning', { processed: scan.processed, total: scan.to_process })}
|
{state}
|
||||||
</div>
|
</div>
|
||||||
<div className={s.bar} role="progressbar" aria-labelledby={id} aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}>
|
{phase === 'walking' && scan.current_dir && <div className={s.scanPath} title={scan.current_dir}>{t('sources.scan_current_dir', { path: scan.current_dir })}</div>}
|
||||||
<span style={{ width: `${pct}%` }} />
|
<div className={cx(s.bar, !determinate && s.indeterminate)} role="progressbar" aria-labelledby={id} aria-valuenow={determinate ? pct : undefined} aria-valuemin={0} aria-valuemax={100}>
|
||||||
|
<span style={determinate ? { width: `${pct}%` } : undefined} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -123,8 +123,14 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.analysisActions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.analysisErrors { display: grid; gap: 16px; list-style: none; margin: 16px 0; padding: 0; }
|
||||||
|
.analysisErrors li { display: grid; gap: 6px; padding-bottom: 16px; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; }
|
||||||
|
.analysisPath { font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
|
||||||
.errorDetail {
|
.errorDetail {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
@@ -146,6 +152,26 @@
|
|||||||
transition: width 0.4s;
|
transition: width 0.4s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scanPath {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text3);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indeterminate span {
|
||||||
|
width: 35%;
|
||||||
|
animation: scanActivity 1.5s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scanActivity {
|
||||||
|
from { transform: translateX(0); }
|
||||||
|
to { transform: translateX(185%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.indeterminate span { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
.test {
|
.test {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
.stats dt { color: var(--text2); font-size: 13px; }
|
.stats dt { color: var(--text2); font-size: 13px; }
|
||||||
.stats dd { margin: 6px 0 0; font-size: 25px; font-variant-numeric: tabular-nums; }
|
.stats dd { margin: 6px 0 0; font-size: 25px; font-variant-numeric: tabular-nums; }
|
||||||
.actions { display: flex; flex-wrap: wrap; gap: 12px; }
|
.actions { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||||
|
.progress { display: grid; gap: 8px; font-variant-numeric: tabular-nums; }
|
||||||
|
.progress progress { display: block; width: 100%; height: 8px; accent-color: var(--accent); }
|
||||||
.lyrics { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.8; }
|
.lyrics { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.8; }
|
||||||
.annotation { border-top: 1px solid var(--line); padding-top: 16px; overflow-wrap: anywhere; }
|
.annotation { border-top: 1px solid var(--line); padding-top: 16px; overflow-wrap: anywhere; }
|
||||||
.annotation summary { cursor: pointer; line-height: 1.6; }
|
.annotation summary { cursor: pointer; line-height: 1.6; }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { shareTrack } from './share';
|
import { sharePlaylist, shareTrack } from './share';
|
||||||
|
import * as desktop from '../../desktop';
|
||||||
|
|
||||||
afterEach(() => vi.restoreAllMocks());
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
@@ -38,3 +39,34 @@ describe('shareTrack', () => {
|
|||||||
finally { vi.unstubAllGlobals(); }
|
finally { vi.unstubAllGlobals(); }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('sharePlaylist', () => {
|
||||||
|
it('copies the shared page URL without exposing streaming links', async () => {
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.stubGlobal('navigator', { userAgent: 'Windows', clipboard: { writeText } });
|
||||||
|
try {
|
||||||
|
expect(await sharePlaylist('token', 'Mix')).toBe('copied');
|
||||||
|
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/shared/playlist/token`);
|
||||||
|
} finally { vi.unstubAllGlobals(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the configured music server when sharing from desktop', async () => {
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(desktop, 'desktopHandle').mockReturnValue({ server: 'https://music.example' } as desktop.DesktopHandle);
|
||||||
|
vi.stubGlobal('navigator', { userAgent: 'Windows', clipboard: { writeText } });
|
||||||
|
try {
|
||||||
|
await sharePlaylist('token', 'Mix');
|
||||||
|
expect(writeText).toHaveBeenCalledWith('https://music.example/shared/playlist/token');
|
||||||
|
} finally { vi.unstubAllGlobals(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the native share sheet on mobile browsers', async () => {
|
||||||
|
const share = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.stubGlobal('navigator', { userAgent: 'Android', share });
|
||||||
|
try {
|
||||||
|
expect(await sharePlaylist('token', 'Mix')).toBe('shared');
|
||||||
|
expect(share).toHaveBeenCalledWith({ title: 'Mix', url: `${window.location.origin}/shared/playlist/token` });
|
||||||
|
} finally { vi.unstubAllGlobals(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
import { trackShareUrl } from '@pipu/core';
|
import { playlistShareUrl, trackShareUrl } from '@pipu/core';
|
||||||
|
import { desktopHandle } from '../../desktop';
|
||||||
|
|
||||||
/** Desktop always copies; mobile browsers can open their native share sheet. */
|
/** Desktop always copies; mobile browsers can open their native share sheet. */
|
||||||
export async function shareTrack(id: string, title: string): Promise<'copied' | 'shared' | 'cancelled'> {
|
export async function shareTrack(id: string, title: string): Promise<'copied' | 'shared' | 'cancelled'> {
|
||||||
const url = trackShareUrl(window.location.origin, id);
|
return shareUrl(trackShareUrl(desktopHandle()?.server ?? window.location.origin, id), title);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sharePlaylist(shareId: string, title: string): Promise<'copied' | 'shared' | 'cancelled'> {
|
||||||
|
return shareUrl(playlistShareUrl(desktopHandle()?.server ?? window.location.origin, shareId), title);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shareUrl(url: string, title: string): Promise<'copied' | 'shared' | 'cancelled'> {
|
||||||
if (/Android|iPhone|iPad|iPod/i.test(navigator.userAgent) && navigator.share) {
|
if (/Android|iPhone|iPad|iPod/i.test(navigator.userAgent) && navigator.share) {
|
||||||
try {
|
try {
|
||||||
await navigator.share({ title, url });
|
await navigator.share({ title, url });
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export function fakeApi(initial: FakeUser[]) {
|
|||||||
let token = 0;
|
let token = 0;
|
||||||
const calls: { method: string; path: string; body: unknown }[] = [];
|
const calls: { method: string; path: string; body: unknown }[] = [];
|
||||||
const reactions = new Map<string, 'like' | 'dislike'>();
|
const reactions = new Map<string, 'like' | 'dislike'>();
|
||||||
|
const playlistOwners = new Map<string, string>();
|
||||||
const playlists = new Map<string, components['schemas']['Playlist']>();
|
const playlists = new Map<string, components['schemas']['Playlist']>();
|
||||||
|
|
||||||
const me = (u: FakeUser): Me => ({
|
const me = (u: FakeUser): Me => ({
|
||||||
@@ -157,20 +158,37 @@ export function fakeApi(initial: FakeUser[]) {
|
|||||||
|
|
||||||
if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized');
|
if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized');
|
||||||
|
|
||||||
if (path === '/api/v1/me/playlists' && req.method === 'GET') return json({ items: [...playlists.values()] });
|
|
||||||
|
const sharedPath = /^\/api\/v1\/shared\/playlists\/([^/]+)$/.exec(path);
|
||||||
|
if (sharedPath && req.method === 'GET') {
|
||||||
|
const item = [...playlists.values()].find(p => p.share_id === sharedPath[1]);
|
||||||
|
return item ? json({ ...item, share_id: null }) : problem(404, 'playlist.not_found');
|
||||||
|
}
|
||||||
|
const sharePath = /^\/api\/v1\/me\/playlists\/([^/]+)\/share$/.exec(path);
|
||||||
|
if (sharePath) {
|
||||||
|
const item = playlists.get(sharePath[1]!);
|
||||||
|
if (!item || playlistOwners.get(item.id) !== user.id) return problem(404, 'playlist.not_found');
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
const share_id = item.share_id ?? `00000000-0000-4000-9001-${String(++seq).padStart(12, '0')}`;
|
||||||
|
playlists.set(item.id, { ...item, share_id }); return json({ share_id });
|
||||||
|
}
|
||||||
|
if (req.method === 'DELETE') { playlists.set(item.id, { ...item, share_id: null }); return new Response(null, { status: 204 }); }
|
||||||
|
}
|
||||||
|
if (path === '/api/v1/me/playlists' && req.method === 'GET') return json({ items: [...playlists.values()].filter(p => !playlistOwners.has(p.id) || playlistOwners.get(p.id) === user.id) });
|
||||||
const playlistPath = /^\/api\/v1\/me\/playlists\/([^/]+)$/.exec(path);
|
const playlistPath = /^\/api\/v1\/me\/playlists\/([^/]+)$/.exec(path);
|
||||||
if (path === '/api/v1/me/playlists' && req.method === 'POST' || playlistPath) {
|
if (path === '/api/v1/me/playlists' && req.method === 'POST' || playlistPath) {
|
||||||
const id = playlistPath?.[1] ?? `00000000-0000-4000-9000-${String(++seq).padStart(12, '0')}`;
|
const id = playlistPath?.[1] ?? `00000000-0000-4000-9000-${String(++seq).padStart(12, '0')}`;
|
||||||
const old = playlists.get(id);
|
const old = playlists.get(id);
|
||||||
if (playlistPath && !old) return problem(404, 'playlist.not_found');
|
if (playlistPath && (!old || (playlistOwners.has(id) && playlistOwners.get(id) !== user.id))) return problem(404, 'playlist.not_found');
|
||||||
if (req.method === 'GET') return json(old);
|
if (req.method === 'GET') return json(old);
|
||||||
if (req.method === 'DELETE') { playlists.delete(id); return new Response(null, { status: 204 }); }
|
if (req.method === 'DELETE') { playlists.delete(id); return new Response(null, { status: 204 }); }
|
||||||
if (old && body?.revision !== old.revision) return problem(409, 'playlist.conflict');
|
if (old && body?.revision !== old.revision) return problem(409, 'playlist.conflict');
|
||||||
const tracks = (body?.track_ids as string[]).map(id => albumTracks.find(t => t.id === id)!);
|
const tracks = (body?.track_ids as string[]).map(id => albumTracks.find(t => t.id === id)!);
|
||||||
const item = { id, name: body?.name as string, description: body?.description as string, tracks,
|
const item = { ...old, id, name: body?.name as string, description: body?.description as string, tracks,
|
||||||
track_count: tracks.length, duration_ms: tracks.reduce((sum, t) => sum + t.duration_ms, 0),
|
track_count: tracks.length, duration_ms: tracks.reduce((sum, t) => sum + t.duration_ms, 0),
|
||||||
revision: (old?.revision ?? 0) + 1, created_at: old?.created_at ?? new Date().toISOString(), updated_at: new Date().toISOString() };
|
revision: (old?.revision ?? 0) + 1, created_at: old?.created_at ?? new Date().toISOString(), updated_at: new Date().toISOString() };
|
||||||
playlists.set(id, item);
|
playlists.set(id, item);
|
||||||
|
if (!old) playlistOwners.set(id, user.id);
|
||||||
return json(item, old ? 200 : 201);
|
return json(item, old ? 200 : 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react';
|
import { wavePalette, WAVE_COLORS } from '@pipu/core';
|
||||||
|
import { useEffect, useMemo, useRef, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react';
|
||||||
|
|
||||||
import { drawLiquid, drawOrb, step, type WaveState } from './draw';
|
import { drawLiquid, drawOrb, step, type WaveState } from './draw';
|
||||||
|
|
||||||
@@ -8,7 +9,7 @@ export interface WaveButtonProps {
|
|||||||
readAudio?: () => { bass: number; mid: number; treble: number };
|
readAudio?: () => { bass: number; mid: number; treble: number };
|
||||||
bpm?: number;
|
bpm?: number;
|
||||||
energy?: number;
|
energy?: number;
|
||||||
colors?: [string, string, string];
|
colors?: readonly string[];
|
||||||
theme?: 'dark' | 'light';
|
theme?: 'dark' | 'light';
|
||||||
interactive?: boolean;
|
interactive?: boolean;
|
||||||
size?: number;
|
size?: number;
|
||||||
@@ -17,8 +18,6 @@ export interface WaveButtonProps {
|
|||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
|
|
||||||
|
|
||||||
function prefersReducedMotion(): boolean {
|
function prefersReducedMotion(): boolean {
|
||||||
return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||||
}
|
}
|
||||||
@@ -29,6 +28,7 @@ function prefersReducedMotion(): boolean {
|
|||||||
*/
|
*/
|
||||||
export function WaveButton(props: WaveButtonProps) {
|
export function WaveButton(props: WaveButtonProps) {
|
||||||
const size = props.size ?? 320;
|
const size = props.size ?? 320;
|
||||||
|
const colors = useMemo(() => wavePalette(props.colors), [props.colors]);
|
||||||
const audioRef = useRef(props.readAudio);
|
const audioRef = useRef(props.readAudio);
|
||||||
audioRef.current = props.readAudio;
|
audioRef.current = props.readAudio;
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
@@ -36,7 +36,7 @@ export function WaveButton(props: WaveButtonProps) {
|
|||||||
const wrapRef = useRef<HTMLDivElement>(null);
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
const st = useRef<WaveState>({
|
const st = useRef<WaveState>({
|
||||||
hover: 0, hoverT: 0, press: 0, pressV: 0, pressT: 0, mx: 0, my: 0, ang: 0, t: 0, amp: 0, pulse: 0,
|
hover: 0, hoverT: 0, press: 0, pressV: 0, pressT: 0, mx: 0, my: 0, ang: 0, t: 0, amp: 0, pulse: 0,
|
||||||
speed: 1, phase: 0, variant: 'orb', playing: false, bpm: 100, energy: 0.6, colors: DEFAULT_COLORS,
|
speed: 1, phase: 0, variant: 'orb', playing: false, bpm: 100, energy: 0.6, colors: WAVE_COLORS,
|
||||||
dark: true, reduced: false, size,
|
dark: true, reduced: false, size,
|
||||||
});
|
});
|
||||||
// Пропсы читаются в цикле анимации через ref — перерисовка React не нужна.
|
// Пропсы читаются в цикле анимации через ref — перерисовка React не нужна.
|
||||||
@@ -45,7 +45,7 @@ export function WaveButton(props: WaveButtonProps) {
|
|||||||
s.playing = !!props.playing;
|
s.playing = !!props.playing;
|
||||||
s.bpm = props.bpm ?? 100;
|
s.bpm = props.bpm ?? 100;
|
||||||
s.energy = props.energy ?? 0.6;
|
s.energy = props.energy ?? 0.6;
|
||||||
s.colors = props.colors ?? DEFAULT_COLORS;
|
s.colors = colors;
|
||||||
s.dark = props.theme !== 'light';
|
s.dark = props.theme !== 'light';
|
||||||
s.size = size;
|
s.size = size;
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,12 @@
|
|||||||
|
|
||||||
Палитра волны по умолчанию: `#2F80FF`, `#7C5CFF`, `#35D3FF`. Логотип — светлая «p»-нота на сфере с радиальным градиентом `#9CC6FF → #2F80FF → #6354E8 → #3A1FA8`.
|
Палитра волны по умолчанию: `#2F80FF`, `#7C5CFF`, `#35D3FF`. Логотип — светлая «p»-нота на сфере с радиальным градиентом `#9CC6FF → #2F80FF → #6354E8 → #3A1FA8`.
|
||||||
|
|
||||||
|
Цвета обложки адаптируются в `wavePalette` (`packages/core/src/wave-anim.ts`) одинаково для веба и Android:
|
||||||
|
основой служит оттенок с наибольшей разницей RGB-каналов (> 0.08), акценты отклоняются на ±28°,
|
||||||
|
насыщенность ограничена 72–82%, светлота — 46–58%. Так тёмные цвета остаются видимыми,
|
||||||
|
а противоположные оттенки не обесцвечивают друг друга при наложении. Для почти бесцветных обложек
|
||||||
|
и отсутствующей или некорректной палитры используется стандартная палитра волны.
|
||||||
|
|
||||||
Исходник значка — `apps/web/public/favicon.svg`; он же используется в шапке веб-приложения.
|
Исходник значка — `apps/web/public/favicon.svg`; он же используется в шапке веб-приложения.
|
||||||
Команда `pnpm icons` пересобирает PNG/ICO/ICNS для desktop, favicon PNG, Apple touch icon
|
Команда `pnpm icons` пересобирает PNG/ICO/ICNS для desktop, favicon PNG, Apple touch icon
|
||||||
и Android-ресурсы в `apps/mobile/assets/`. Android использует отдельные адаптивный цветной
|
и Android-ресурсы в `apps/mobile/assets/`. Android использует отдельные адаптивный цветной
|
||||||
|
|||||||
@@ -139,6 +139,19 @@ lower-case, `ё → е`, удаление пунктуации, схлопыва
|
|||||||
|
|
||||||
Ожидаемое время первого скана 11k файлов по NFS — 5–15 минут (bottleneck — чтение тегов).
|
Ожидаемое время первого скана 11k файлов по NFS — 5–15 минут (bottleneck — чтение тегов).
|
||||||
|
|
||||||
|
Во время обхода админка показывает число посещённых папок (включая корень), найденных
|
||||||
|
аудиофайлов и текущую папку относительно корня хранилища. До завершения обхода общее
|
||||||
|
число файлов неизвестно, поэтому индикатор показывает активность без процента.
|
||||||
|
Затем отображаются этапы сопоставления, обработки файлов с известным общим числом
|
||||||
|
и завершения скана. API передаёт их в `ScanProgress.phase`, `walked_dirs`, `current_dir`.
|
||||||
|
|
||||||
|
Ошибки в итогах скана относятся только к этому запуску. Общий счётчик и список
|
||||||
|
ошибок чтения учитывают сохранённые ошибки только присутствующих файлов:
|
||||||
|
после успешного скана пропавшие файлы исключаются из них, сохраняя запись на срок
|
||||||
|
`MissingRetention`. В блоке аудиоанализа администратор может открыть «Показать файлы»:
|
||||||
|
окно загружает ошибки анализа страницами через `GET /api/v1/admin/library/analysis/errors`
|
||||||
|
и показывает трек, хранилище, относительный путь из текущего каталога и причину сбоя.
|
||||||
|
|
||||||
### Обложки
|
### Обложки
|
||||||
|
|
||||||
Источники по приоритету: встроенная в файл → `cover.jpg|folder.jpg|front.*` в папке альбома → плейсхолдер,
|
Источники по приоритету: встроенная в файл → `cover.jpg|folder.jpg|front.*` в папке альбома → плейсхолдер,
|
||||||
|
|||||||
+21
-2
@@ -1,6 +1,6 @@
|
|||||||
# Личные плейлисты
|
# Личные плейлисты
|
||||||
|
|
||||||
Плейлисты хранятся в PostgreSQL и доступны владельцу на вебе и в React Native-клиенте. В вебе раздел находится в коллекции; на узком экране — в меню «Ещё». В мобильном клиенте — кнопка «Плейлисты» в коллекции.
|
Плейлисты хранятся в PostgreSQL. Владелец управляет ими в вебе, desktop и React Native-клиенте; по желанию может поделиться ссылкой для прослушивания. В вебе раздел находится в коллекции; на узком экране — в меню «Ещё». В мобильном клиенте — кнопка «Плейлисты» в коллекции.
|
||||||
|
|
||||||
Можно создать плейлист, изменить название и описание, добавить трек из поиска, альбома или коллекции, убрать трек и переместить его выше/ниже. В вебе можно добавить целый альбом. Один трек встречается в плейлисте один раз: повторное добавление не создаёт дубль. Воспроизведение доступно в сохранённом или случайном порядке; недоступные треки остаются видимыми, но пропускаются при запуске всего плейлиста. Удаление плейлиста требует подтверждения и не удаляет музыку из каталога.
|
Можно создать плейлист, изменить название и описание, добавить трек из поиска, альбома или коллекции, убрать трек и переместить его выше/ниже. В вебе можно добавить целый альбом. Один трек встречается в плейлисте один раз: повторное добавление не создаёт дубль. Воспроизведение доступно в сохранённом или случайном порядке; недоступные треки остаются видимыми, но пропускаются при запуске всего плейлиста. Удаление плейлиста требует подтверждения и не удаляет музыку из каталога.
|
||||||
|
|
||||||
@@ -11,12 +11,31 @@
|
|||||||
- `GET /api/v1/me/playlists/{id}` — метаданные и упорядоченные треки с персональными реакциями и подписанными ссылками.
|
- `GET /api/v1/me/playlists/{id}` — метаданные и упорядоченные треки с персональными реакциями и подписанными ссылками.
|
||||||
- `PUT /api/v1/me/playlists/{id}` — атомарная замена метаданных и порядка треков; дополнительно требуется `revision` из прочитанного плейлиста.
|
- `PUT /api/v1/me/playlists/{id}` — атомарная замена метаданных и порядка треков; дополнительно требуется `revision` из прочитанного плейлиста.
|
||||||
- `DELETE /api/v1/me/playlists/{id}` — удаление.
|
- `DELETE /api/v1/me/playlists/{id}` — удаление.
|
||||||
|
- `POST /api/v1/me/playlists/{id}/share` — создать или вернуть действующий `share_id` (только владелец).
|
||||||
|
- `DELETE /api/v1/me/playlists/{id}/share` — отключить доступ по ссылке (только владелец).
|
||||||
|
- `GET /api/v1/shared/playlists/{shareId}` — прочитать плейлист по действующей ссылке; требуется вход на этот сервер.
|
||||||
|
|
||||||
Все операции требуют авторизации. Чужой и отсутствующий плейлист возвращают одинаковый `404`. Изменения блокируют строку плейлиста в транзакции; устаревшая версия возвращает `409 playlist.conflict`. Редактор сохраняет исходную версию до закрытия, даже если фоновый запрос получил новую. Ошибка трека откатывает изменение целиком. Ввод ограничен 120 символами названия, 2000 символами описания и 2000 уникальными треками.
|
Все операции требуют авторизации. Чужой и отсутствующий плейлист возвращают одинаковый `404`. Изменения блокируют строку плейлиста в транзакции; устаревшая версия возвращает `409 playlist.conflict`. Редактор сохраняет исходную версию до закрытия, даже если фоновый запрос получил новую. Ошибка трека откатывает изменение целиком. Ввод ограничен 120 символами названия, 2000 символами описания и 2000 уникальными треками.
|
||||||
|
|
||||||
Миграция `00013_playlists.sql` создаёт таблицы `playlists`, `playlist_tracks` и индексы. Удаление пользователя очищает его плейлисты; удаление трека из каталога очищает его членство. Таблицы не затрагивают музыкальные файлы.
|
Миграция `00013_playlists.sql` создаёт таблицы `playlists`, `playlist_tracks` и индексы. Удаление пользователя очищает его плейлисты; удаление трека из каталога очищает его членство. Таблицы не затрагивают музыкальные файлы.
|
||||||
|
|
||||||
Плейлисты личные; публичные ссылки, совместное редактирование и загрузка пользовательских обложек не входят в эту реализацию. Карточки имеют градиентную обложку, страница использует обложку первого трека. Контекст воспроизведения — `collection` с UUID и названием плейлиста.
|
Изначально плейлисты личные. Кнопка «Поделиться плейлистом» включает доступ по ссылке
|
||||||
|
`/shared/playlist/{shareId}`. В вебе/desktop ссылка копируется, в мобильном браузере и Android открывается
|
||||||
|
системное меню «Поделиться». Desktop использует адрес музыкального сервера, а не локального WebView.
|
||||||
|
Как и ссылки на треки, ссылка требует входа на тот же сервер. После входа открывается запрошенный плейлист,
|
||||||
|
без автоматического запуска музыки. Получатель может слушать в обычном или случайном порядке и переслать
|
||||||
|
ту же ссылку, но не может редактировать или удалять исходный плейлист.
|
||||||
|
|
||||||
|
Ссылка содержит отдельный случайный UUID и не раскрывает токены авторизации или подписанные URL аудио.
|
||||||
|
Список `/me/playlists` и обычное чтение по UUID плейлиста остаются доступны только владельцу. Реакции
|
||||||
|
на треки и ссылки стрима в общем плейлисте принадлежат текущему слушателю. Данные общих страниц в веб-кэше
|
||||||
|
разделяются по пользователям.
|
||||||
|
|
||||||
|
«Отключить доступ по ссылке» отзывает прежний `share_id`; следующая публикация создаёт новый.
|
||||||
|
Повторное нажатие «Поделиться» без отзыва сохраняет ссылку. Ссылка показывает актуальный порядок и метаданные;
|
||||||
|
изменение доступа не меняет `revision` и не вызывает конфликт редактора. При удалении плейлиста или блокировке
|
||||||
|
владельца ссылка возвращает `404`. Миграция `00016_playlist_sharing.sql` добавляет `share_id` и уникальный индекс.
|
||||||
|
Совместное редактирование и загрузка пользовательских обложек не входят в эту реализацию. Карточки имеют градиентную обложку, страница использует обложку первого трека. Контекст воспроизведения — `collection` с UUID и названием плейлиста.
|
||||||
|
|
||||||
## Проверки
|
## Проверки
|
||||||
|
|
||||||
|
|||||||
@@ -860,6 +860,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/admin/library/analysis/errors": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Неудавшиеся задачи аудиоанализа с путями файлов (admin) */
|
||||||
|
get: operations["listAnalysisErrors"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/admin/library/host-dirs": {
|
"/api/v1/admin/library/host-dirs": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1439,10 +1456,53 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/me/playlists/{id}/share": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Создать или вернуть ссылку только для чтения (только владелец) */
|
||||||
|
post: operations["sharePlaylist"];
|
||||||
|
/** Отозвать ссылку (только владелец); повторная публикация создаёт новую ссылку */
|
||||||
|
delete: operations["revokePlaylistShare"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/shared/playlists/{shareId}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
shareId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Прочитать плейлист по действующей ссылке (требуется вход) */
|
||||||
|
get: operations["getSharedPlaylist"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
schemas: {
|
schemas: {
|
||||||
|
PlaylistShare: {
|
||||||
|
/** Format: uuid */
|
||||||
|
share_id: string;
|
||||||
|
};
|
||||||
PlaylistInput: {
|
PlaylistInput: {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -1466,6 +1526,11 @@ export interface components {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
Playlist: components["schemas"]["PlaylistSummary"] & {
|
Playlist: components["schemas"]["PlaylistSummary"] & {
|
||||||
|
/**
|
||||||
|
* Format: uuid
|
||||||
|
* @description Действующая ссылка; возвращается только владельцу через /me/playlists
|
||||||
|
*/
|
||||||
|
readonly share_id?: string | null;
|
||||||
tracks: components["schemas"]["Track"][];
|
tracks: components["schemas"]["Track"][];
|
||||||
};
|
};
|
||||||
PlaylistList: {
|
PlaylistList: {
|
||||||
@@ -2030,6 +2095,15 @@ export interface components {
|
|||||||
full?: boolean;
|
full?: boolean;
|
||||||
};
|
};
|
||||||
ScanProgress: {
|
ScanProgress: {
|
||||||
|
/**
|
||||||
|
* @description Current scan stage; progress percentage is available only during processing.
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
phase?: "walking" | "preparing" | "processing" | "finalizing";
|
||||||
|
/** @description Directories visited so far, including the source root. */
|
||||||
|
walked_dirs?: number;
|
||||||
|
/** @description Current directory relative to the source root, using forward slashes. */
|
||||||
|
current_dir?: string;
|
||||||
running: boolean;
|
running: boolean;
|
||||||
full: boolean;
|
full: boolean;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
@@ -2079,6 +2153,21 @@ export interface components {
|
|||||||
items: components["schemas"]["LibraryError"][];
|
items: components["schemas"]["LibraryError"][];
|
||||||
next_cursor: string | null;
|
next_cursor: string | null;
|
||||||
};
|
};
|
||||||
|
AnalysisError: {
|
||||||
|
/** Format: uuid */
|
||||||
|
track_id: string;
|
||||||
|
title: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
source_id: string | null;
|
||||||
|
source_name: string | null;
|
||||||
|
/** @description Path relative to the source root; primary file if available, otherwise a known file of this track. */
|
||||||
|
rel_path: string | null;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
AnalysisErrorPage: {
|
||||||
|
items: components["schemas"]["AnalysisError"][];
|
||||||
|
next_cursor: string | null;
|
||||||
|
};
|
||||||
HostDirList: {
|
HostDirList: {
|
||||||
path: string;
|
path: string;
|
||||||
dirs: string[];
|
dirs: string[];
|
||||||
@@ -3712,6 +3801,31 @@ export interface operations {
|
|||||||
default: components["responses"]["Problem"];
|
default: components["responses"]["Problem"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
listAnalysisErrors: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
limit?: components["parameters"]["Limit"];
|
||||||
|
/** @description Непрозрачный курсор из `next_cursor` предыдущей страницы */
|
||||||
|
cursor?: components["parameters"]["Cursor"];
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Страница ошибок аудиоанализа, упорядоченная по ID трека */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AnalysisErrorPage"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
default: components["responses"]["Problem"];
|
||||||
|
};
|
||||||
|
};
|
||||||
listHostDirs: {
|
listHostDirs: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
@@ -4607,4 +4721,71 @@ export interface operations {
|
|||||||
default: components["responses"]["Problem"];
|
default: components["responses"]["Problem"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
sharePlaylist: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Идентификатор ссылки для /shared/playlist/{shareId}; для открытия требуется вход */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PlaylistShare"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
default: components["responses"]["Problem"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
revokePlaylistShare: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Доступ по прежней ссылке отключён */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
default: components["responses"]["Problem"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
getSharedPlaylist: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
shareId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Плейлист с реакциями и ссылками на стрим для текущего слушателя */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Playlist"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
default: components["responses"]["Problem"];
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -22,9 +22,9 @@ export {
|
|||||||
type Track,
|
type Track,
|
||||||
} from './player';
|
} from './player';
|
||||||
export { normalizeGain, TARGET_LUFS } from './gain';
|
export { normalizeGain, TARGET_LUFS } from './gain';
|
||||||
export { trackShareUrl } from './share';
|
export { playlistShareUrl, trackShareUrl } from './share';
|
||||||
export { checkServer, normalizeServerUrl, type ServerCheck } from './server';
|
export { checkServer, normalizeServerUrl, type ServerCheck } from './server';
|
||||||
export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, waveScale, type Rgb, type WaveState } from './wave-anim';
|
export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, wavePalette, waveScale, type Rgb, type WaveState } from './wave-anim';
|
||||||
|
|
||||||
export { WaveCoordinator, EARLY_SKIP_MS, WAVE_REFILL_AT, type WaveInfo, type WaveOutcome, type WaveFeedback, type WaveGateway } from './wave';
|
export { WaveCoordinator, EARLY_SKIP_MS, WAVE_REFILL_AT, type WaveInfo, type WaveOutcome, type WaveFeedback, type WaveGateway } from './wave';
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { trackShareUrl } from './share';
|
import { playlistShareUrl, trackShareUrl } from './share';
|
||||||
|
|
||||||
describe('trackShareUrl', () => {
|
describe('trackShareUrl', () => {
|
||||||
it('builds a page URL without stream credentials', () => {
|
it('builds a page URL without stream credentials', () => {
|
||||||
expect(trackShareUrl('https://music.example', 'ab/cd')).toBe('https://music.example/track/ab%2Fcd');
|
expect(trackShareUrl('https://music.example', 'ab/cd')).toBe('https://music.example/track/ab%2Fcd');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it('builds a shared playlist URL from the server without stream credentials', () => {
|
||||||
|
expect(playlistShareUrl('https://music.example/api/v1', 'ab/cd')).toBe('https://music.example/shared/playlist/ab%2Fcd');
|
||||||
|
});
|
||||||
@@ -2,3 +2,8 @@
|
|||||||
export function trackShareUrl(server: string, trackId: string): string {
|
export function trackShareUrl(server: string, trackId: string): string {
|
||||||
return new URL(`/track/${encodeURIComponent(trackId)}`, server).href;
|
return new URL(`/track/${encodeURIComponent(trackId)}`, server).href;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Revocable read-only playlist page; requires a listener account on this server. */
|
||||||
|
export function playlistShareUrl(server: string, shareId: string): string {
|
||||||
|
return new URL(`/shared/playlist/${encodeURIComponent(shareId)}`, server).href;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { rgb, wavePalette, WAVE_COLORS } from './wave-anim';
|
||||||
|
|
||||||
|
function hsl(color: string) {
|
||||||
|
const [r, g, b] = rgb(color).map((v) => v / 255) as [number, number, number];
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
const c = max - min;
|
||||||
|
const l = (max + min) / 2;
|
||||||
|
const h = c === 0 ? 0 : max === r ? (g - b) / c : max === g ? (b - r) / c + 2 : (r - g) / c + 4;
|
||||||
|
return { h: (h * 60 + 360) % 360, s: c === 0 ? 0 : c / (1 - Math.abs(2 * l - 1)), l };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('wave cover palette', () => {
|
||||||
|
it.each([
|
||||||
|
undefined, [], ['#fff'], ['#000', '#888', '#fff'],
|
||||||
|
['#010203', '#fefdfc', '#808182'], ['#ff0000', 'invalid', '#0000ff'],
|
||||||
|
])('uses the default palette for missing, neutral or malformed colors: %j', (colors) => {
|
||||||
|
expect(wavePalette(colors)).toEqual(WAVE_COLORS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores neutral colors and preserves the strongest cover hue', () => {
|
||||||
|
const colors = ['#ffffff', '#007733', '#222222'];
|
||||||
|
const before = [...colors];
|
||||||
|
expect(hsl(wavePalette(colors)[0]).h).toBeCloseTo(hsl('#007733').h, 0);
|
||||||
|
expect(wavePalette(colors)).toEqual(wavePalette(['#007733', '#222222', '#ffffff']));
|
||||||
|
expect(colors).toEqual(before);
|
||||||
|
expect(wavePalette(['#fff', '#073', '#222'])).toEqual(wavePalette(colors));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['#040e08', '#18281d', '#374450'], // dark, muted cover like the reported case
|
||||||
|
['#ff0000', '#00ff00', '#0000ff'], // opposing hues must not mix to gray
|
||||||
|
['#ffff00', '#ffffff', '#000000'],
|
||||||
|
['#ff0080', '#540029', '#fefefe'], // hue offsets wrap around red
|
||||||
|
])('keeps a visible, saturated, harmonious palette for %j', (...colors) => {
|
||||||
|
const palette = wavePalette(colors);
|
||||||
|
const base = hsl(palette[0]);
|
||||||
|
for (const color of palette) {
|
||||||
|
expect(color).toMatch(/^#[\da-f]{6}$/i);
|
||||||
|
const { h, s, l } = hsl(color);
|
||||||
|
expect(s).toBeGreaterThanOrEqual(0.70);
|
||||||
|
expect(s).toBeLessThanOrEqual(0.84);
|
||||||
|
expect(l).toBeGreaterThanOrEqual(0.45);
|
||||||
|
expect(l).toBeLessThanOrEqual(0.59);
|
||||||
|
const distance = Math.abs(h - base.h);
|
||||||
|
expect(Math.min(distance, 360 - distance)).toBeLessThan(29);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -30,6 +30,39 @@ export interface WaveState {
|
|||||||
|
|
||||||
export const WAVE_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
|
export const WAVE_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
|
||||||
|
|
||||||
|
/** Keep the cover's strongest hue, but avoid muddy or complementary additive blends. */
|
||||||
|
export function wavePalette(colors?: readonly string[]): [string, string, string] {
|
||||||
|
if (colors?.length !== 3 || !colors.every((c) => /^#(?:[\da-f]{3}|[\da-f]{6})$/i.test(c))) {
|
||||||
|
return [...WAVE_COLORS];
|
||||||
|
}
|
||||||
|
let hue = 0;
|
||||||
|
let strongest = 0;
|
||||||
|
for (const color of colors) {
|
||||||
|
const [r, g, b] = rgb(color).map((v) => v / 255) as Rgb;
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
const chroma = max - min;
|
||||||
|
// Absolute chroma rejects both near-black noise and nearly white/gray covers.
|
||||||
|
if (chroma <= Math.max(0.08, strongest)) continue;
|
||||||
|
strongest = chroma;
|
||||||
|
hue = max === r ? ((g - b) / chroma) % 6 : max === g ? (b - r) / chroma + 2 : (r - g) / chroma + 4;
|
||||||
|
hue = (hue * 60 + 360) % 360;
|
||||||
|
}
|
||||||
|
if (!strongest) return [...WAVE_COLORS];
|
||||||
|
// A narrow hue range retains color when the translucent layers overlap.
|
||||||
|
return [paletteColor(hue, 0.78, 0.50), paletteColor(hue + 28, 0.72, 0.46), paletteColor(hue - 28, 0.82, 0.58)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function paletteColor(hue: number, saturation: number, lightness: number): string {
|
||||||
|
const h = ((hue % 360) + 360) % 360 / 60;
|
||||||
|
const c = (1 - Math.abs(2 * lightness - 1)) * saturation;
|
||||||
|
const x = c * (1 - Math.abs(h % 2 - 1));
|
||||||
|
const m = lightness - c / 2;
|
||||||
|
const channels = h < 1 ? [c, x, 0] : h < 2 ? [x, c, 0] : h < 3 ? [0, c, x]
|
||||||
|
: h < 4 ? [0, x, c] : h < 5 ? [x, 0, c] : [c, 0, x];
|
||||||
|
return `#${channels.map((v) => Math.round((v + m) * 255).toString(16).padStart(2, '0')).join('')}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function initialWaveState(size: number): WaveState {
|
export function initialWaveState(size: number): WaveState {
|
||||||
return {
|
return {
|
||||||
hover: 0, hoverT: 0, press: 0, pressV: 0, pressT: 0, mx: 0, my: 0, ang: 0, t: 0, amp: 0, pulse: 0,
|
hover: 0, hoverT: 0, press: 0, pressV: 0, pressT: 0, mx: 0, my: 0, ang: 0, t: 0, amp: 0, pulse: 0,
|
||||||
|
|||||||
@@ -98,7 +98,11 @@
|
|||||||
"delete_confirm": "Delete “{{name}}”? Tracks that exist only in this storage will disappear from the library along with their likes.",
|
"delete_confirm": "Delete “{{name}}”? Tracks that exist only in this storage will disappear from the library along with their likes.",
|
||||||
"scanning": "Scanning: {{processed}} of {{total}}",
|
"scanning": "Scanning: {{processed}} of {{total}}",
|
||||||
"scan_walking": "Walking folders…",
|
"scan_walking": "Walking folders…",
|
||||||
"scan_done": "Found {{found}} · new {{added}} · updated {{updated}} · moved {{moved}} · missing {{missing}} · errors {{errors}}",
|
"scan_walk_progress": "Walking folders… Directories checked: {{dirs}} · audio files found: {{found}}",
|
||||||
|
"scan_current_dir": "Current folder: {{path}}",
|
||||||
|
"scan_preparing": "Matching files with the library…",
|
||||||
|
"scan_finalizing": "Finishing scan…",
|
||||||
|
"scan_done": "Found {{found}} · new {{added}} · updated {{updated}} · moved {{moved}} · missing {{missing}} · errors in this scan {{errors}}",
|
||||||
"scan_failed": "Scan stopped: {{error}}",
|
"scan_failed": "Scan stopped: {{error}}",
|
||||||
"test_ok": "Reachable, audio files found: {{count}}",
|
"test_ok": "Reachable, audio files found: {{count}}",
|
||||||
"test_fail": "Not reachable: {{message}}",
|
"test_fail": "Not reachable: {{message}}",
|
||||||
@@ -172,7 +176,15 @@
|
|||||||
"wave_likes": "Likes per 100",
|
"wave_likes": "Likes per 100",
|
||||||
"wave_model": "Model version",
|
"wave_model": "Model version",
|
||||||
"wave_by_model": "Wave model versions compared",
|
"wave_by_model": "Wave model versions compared",
|
||||||
"analysis_retry": "Retry"
|
"analysis_retry": "Retry",
|
||||||
|
"analysis_show_errors": "Show files",
|
||||||
|
"analysis_errors_title": "Audio analysis errors",
|
||||||
|
"analysis_errors_hint": "Each track shows its storage, path relative to the storage root and failure reason. Paths come from the current library catalog.",
|
||||||
|
"analysis_errors_empty": "There are no failed analysis jobs.",
|
||||||
|
"analysis_errors_more": "Show more",
|
||||||
|
"analysis_error_source": "Storage: {{name}}",
|
||||||
|
"analysis_error_no_file": "This track no longer has a file in the library.",
|
||||||
|
"analysis_error_unknown": "No error reason was recorded."
|
||||||
},
|
},
|
||||||
"write_template": {
|
"write_template": {
|
||||||
"title": "Uploads and path template",
|
"title": "Uploads and path template",
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
{
|
{
|
||||||
"genius": {
|
"genius": {
|
||||||
"description": "Lyrics, annotations and missing artwork from Genius. Local artwork takes priority.",
|
"description": "Lyrics, annotations and missing artwork from Genius. Local artwork takes priority.",
|
||||||
"loading": "Loading…", "disabled": "Not configured", "paused": "Paused", "cooldown": "Waiting after Genius rate limit", "running": "Processing", "waiting": "Waiting for new tracks",
|
"loading": "Loading…", "disabled": "Not configured", "paused": "Processing paused", "cooldown": "Waiting after Genius rate limit", "running": "Fetching data from Genius…", "queued": "Queue awaiting processing", "waiting": "Waiting for new tracks",
|
||||||
|
"progress": "Processed {{processed}} of {{total}} tracks",
|
||||||
|
"progress_label": "Genius processing progress",
|
||||||
|
"progress_hint": "Processed tracks include errors and unmatched tracks. Data refreshes every 5 seconds.",
|
||||||
|
"controls_hint": "The queue is processed automatically. Pausing saves progress; resuming continues with the remaining tracks.",
|
||||||
|
"retry_hint": "Retry queues only tracks with errors, incomplete data or no match, and resumes processing. Successfully processed tracks stay unchanged.",
|
||||||
"configure": "Add GENIUS_ACCESS_TOKEN to deploy/.env and recreate the API container.",
|
"configure": "Add GENIUS_ACCESS_TOKEN to deploy/.env and recreate the API container.",
|
||||||
"total": "Total tracks", "pending": "Queued", "matched": "Matched on Genius", "lyrics": "Lyrics", "annotations": "With annotations", "images": "With artwork", "unmatched": "No exact match", "errors": "Errors / incomplete data",
|
"total": "Total tracks", "pending": "Queued", "matched": "Matched on Genius", "lyrics": "Lyrics", "annotations": "With annotations", "images": "With artwork", "unmatched": "No exact match", "errors": "Errors / incomplete data",
|
||||||
"last_error": "Last error", "token_error": "Genius rejected the token. Check it and restart the API.", "access_error": "Genius restricted access. Processing is paused.", "no_lyrics": "Lyrics are currently unavailable.", "request_error": "Some data could not be retrieved. A retry is scheduled automatically.", "resume_at": "Retry no earlier than",
|
"last_error": "Last error", "token_error": "Genius rejected the token. Check it and restart the API.", "access_error": "Genius restricted access. Processing is paused.", "no_lyrics": "Lyrics are currently unavailable.", "request_error": "Some data could not be retrieved. A retry is scheduled automatically.", "resume_at": "Retry no earlier than",
|
||||||
"start": "Resume", "pause": "Pause", "retry": "Retry errors and unmatched", "pending_text": "This track is waiting for Genius processing.", "about": "About the song", "explanations": "Song annotations", "verified": "Verified on Genius"
|
"start": "Resume processing", "pause": "Pause processing", "retry": "Queue tracks needing attention", "pending_text": "This track is waiting for Genius processing.", "about": "About the song", "explanations": "Song annotations", "verified": "Verified on Genius"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"name": "pipu",
|
"name": "pipu",
|
||||||
@@ -340,6 +345,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"playlists": {
|
"playlists": {
|
||||||
|
"share": "Share playlist",
|
||||||
|
"link_copied": "Playlist link copied",
|
||||||
|
"shared": "Shared by link",
|
||||||
|
"revoke_share": "Disable link access",
|
||||||
|
"share_hint": "Anyone with an account on this server and this link can listen. Only you can edit the playlist.",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"delete_confirm": "Delete playlist “{{name}}”? Tracks will remain in your library.",
|
"delete_confirm": "Delete playlist “{{name}}”? Tracks will remain in your library.",
|
||||||
"manage": "Manage tracks",
|
"manage": "Manage tracks",
|
||||||
|
|||||||
@@ -98,7 +98,11 @@
|
|||||||
"delete_confirm": "Удалить «{{name}}»? Треки, которые есть только в этом хранилище, пропадут из библиотеки вместе с лайками.",
|
"delete_confirm": "Удалить «{{name}}»? Треки, которые есть только в этом хранилище, пропадут из библиотеки вместе с лайками.",
|
||||||
"scanning": "Сканирование: {{processed}} из {{total}}",
|
"scanning": "Сканирование: {{processed}} из {{total}}",
|
||||||
"scan_walking": "Обход папок…",
|
"scan_walking": "Обход папок…",
|
||||||
"scan_done": "Найдено {{found}} · новых {{added}} · обновлено {{updated}} · перемещено {{moved}} · пропало {{missing}} · ошибок {{errors}}",
|
"scan_walk_progress": "Обход папок… Проверено папок: {{dirs}} · найдено аудиофайлов: {{found}}",
|
||||||
|
"scan_current_dir": "Текущая папка: {{path}}",
|
||||||
|
"scan_preparing": "Сопоставление файлов с библиотекой…",
|
||||||
|
"scan_finalizing": "Завершение сканирования…",
|
||||||
|
"scan_done": "Найдено {{found}} · новых {{added}} · обновлено {{updated}} · перемещено {{moved}} · пропало {{missing}} · ошибок в этом скане {{errors}}",
|
||||||
"scan_failed": "Скан прервался: {{error}}",
|
"scan_failed": "Скан прервался: {{error}}",
|
||||||
"test_ok": "Доступно, найдено аудиофайлов: {{count}}",
|
"test_ok": "Доступно, найдено аудиофайлов: {{count}}",
|
||||||
"test_fail": "Нет доступа: {{message}}",
|
"test_fail": "Нет доступа: {{message}}",
|
||||||
@@ -172,7 +176,15 @@
|
|||||||
"wave_likes": "Лайков на 100",
|
"wave_likes": "Лайков на 100",
|
||||||
"wave_model": "Версия модели",
|
"wave_model": "Версия модели",
|
||||||
"wave_by_model": "Сравнение версий модели волны",
|
"wave_by_model": "Сравнение версий модели волны",
|
||||||
"analysis_retry": "Повторить"
|
"analysis_retry": "Повторить",
|
||||||
|
"analysis_show_errors": "Показать файлы",
|
||||||
|
"analysis_errors_title": "Ошибки аудиоанализа",
|
||||||
|
"analysis_errors_hint": "Для каждого трека показаны хранилище, путь относительно его корня и причина сбоя. Пути берутся из текущего каталога библиотеки.",
|
||||||
|
"analysis_errors_empty": "Неудавшихся задач анализа нет.",
|
||||||
|
"analysis_errors_more": "Показать ещё",
|
||||||
|
"analysis_error_source": "Хранилище: {{name}}",
|
||||||
|
"analysis_error_no_file": "В библиотеке больше нет файла этого трека.",
|
||||||
|
"analysis_error_unknown": "Причина ошибки не указана."
|
||||||
},
|
},
|
||||||
"write_template": {
|
"write_template": {
|
||||||
"title": "Приём загрузок и шаблон пути",
|
"title": "Приём загрузок и шаблон пути",
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
{
|
{
|
||||||
"genius": {
|
"genius": {
|
||||||
"description": "Тексты, пояснения и недостающие картинки из Genius. Локальные обложки имеют приоритет.",
|
"description": "Тексты, пояснения и недостающие картинки из Genius. Локальные обложки имеют приоритет.",
|
||||||
"loading": "Загрузка…", "disabled": "Не подключён", "paused": "Приостановлено", "cooldown": "Ожидание после ограничения Genius", "running": "Обработка", "waiting": "Ожидание новых треков",
|
"loading": "Загрузка…", "disabled": "Не подключён", "paused": "Обработка приостановлена", "cooldown": "Ожидание после ограничения Genius", "running": "Получение данных из Genius…", "queued": "Очередь ожидает обработки", "waiting": "Ожидание новых треков",
|
||||||
|
"progress": "Обработано {{processed}} из {{total}} треков",
|
||||||
|
"progress_label": "Прогресс обработки Genius",
|
||||||
|
"progress_hint": "В обработанные входят треки с ошибками и без совпадения. Данные обновляются каждые 5 секунд.",
|
||||||
|
"controls_hint": "Очередь обрабатывается автоматически. Пауза сохраняет прогресс; продолжение возобновляет обработку оставшихся треков.",
|
||||||
|
"retry_hint": "Повтор добавит в очередь только треки с ошибками, неполными данными и без совпадения, а также снимет паузу. Успешно обработанные треки останутся без изменений.",
|
||||||
"configure": "Добавьте GENIUS_ACCESS_TOKEN в deploy/.env и пересоздайте контейнер API.",
|
"configure": "Добавьте GENIUS_ACCESS_TOKEN в deploy/.env и пересоздайте контейнер API.",
|
||||||
"total": "Всего треков", "pending": "В очереди", "matched": "Найдено в Genius", "lyrics": "Тексты", "annotations": "С пояснениями", "images": "С картинками", "unmatched": "Нет точного совпадения", "errors": "Ошибки / неполные данные",
|
"total": "Всего треков", "pending": "В очереди", "matched": "Найдено в Genius", "lyrics": "Тексты", "annotations": "С пояснениями", "images": "С картинками", "unmatched": "Нет точного совпадения", "errors": "Ошибки / неполные данные",
|
||||||
"last_error": "Последняя ошибка", "token_error": "Genius отклонил токен. Проверьте его и перезапустите API.", "access_error": "Genius ограничил доступ. Обработка приостановлена.", "no_lyrics": "Текст пока недоступен.", "request_error": "Не удалось получить все данные. Повтор запланирован автоматически.", "resume_at": "Повтор не раньше",
|
"last_error": "Последняя ошибка", "token_error": "Genius отклонил токен. Проверьте его и перезапустите API.", "access_error": "Genius ограничил доступ. Обработка приостановлена.", "no_lyrics": "Текст пока недоступен.", "request_error": "Не удалось получить все данные. Повтор запланирован автоматически.", "resume_at": "Повтор не раньше",
|
||||||
"start": "Продолжить", "pause": "Приостановить", "retry": "Повторить ошибки и ненайденные", "pending_text": "Трек ожидает обработки Genius.", "about": "О песне", "explanations": "Пояснения к песне", "verified": "Подтверждено в Genius"
|
"start": "Продолжить обработку", "pause": "Приостановить обработку", "retry": "Добавить проблемные треки в очередь", "pending_text": "Трек ожидает обработки Genius.", "about": "О песне", "explanations": "Пояснения к песне", "verified": "Подтверждено в Genius"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"name": "pipu",
|
"name": "pipu",
|
||||||
@@ -340,6 +345,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"playlists": {
|
"playlists": {
|
||||||
|
"share": "Поделиться плейлистом",
|
||||||
|
"link_copied": "Ссылка на плейлист скопирована",
|
||||||
|
"shared": "Доступ по ссылке",
|
||||||
|
"revoke_share": "Отключить доступ по ссылке",
|
||||||
|
"share_hint": "Любой пользователь этого сервера со ссылкой сможет слушать плейлист. Изменять его можете только вы.",
|
||||||
"name": "Название",
|
"name": "Название",
|
||||||
"delete_confirm": "Удалить плейлист «{{name}}»? Треки останутся в библиотеке.",
|
"delete_confirm": "Удалить плейлист «{{name}}»? Треки останутся в библиотеке.",
|
||||||
"manage": "Управлять треками",
|
"manage": "Управлять треками",
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,90 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package dbtest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"pipu/api/internal/library"
|
||||||
|
"pipu/api/internal/platform/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLibraryDiagnostics(t *testing.T) {
|
||||||
|
pool := connect(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
require.NoError(t, db.Migrate(ctx, pool))
|
||||||
|
store := library.NewStore(pool)
|
||||||
|
src, err := store.SaveSource(ctx, library.Source{ID: uuid.New(), Name: "diagnostics", Type: library.SourceLocal, Status: "mounted", TagsFirst: true, NFSOptions: "ro"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
require.NoError(t, store.DeleteSource(ctx, src.ID))
|
||||||
|
require.NoError(t, store.CleanupOrphans(ctx))
|
||||||
|
})
|
||||||
|
before, err := store.Counts(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
badID, err := store.SaveFile(ctx, src.ID, uuid.Nil, library.FileData{RelPath: "broken.mp3", Mtime: time.Now(), ScanError: "invalid audio"}, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
counts, err := store.Counts(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, before.Errors+1, counts.Errors)
|
||||||
|
require.NoError(t, store.MarkMissing(ctx, []uuid.UUID{badID}, time.Now()))
|
||||||
|
counts, err = store.Counts(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, before.Errors, counts.Errors)
|
||||||
|
fileErrors, err := store.FileErrors(ctx, "", 200)
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, item := range fileErrors {
|
||||||
|
require.NotEqual(t, badID, item.ID, "absent files must not remain in read errors")
|
||||||
|
}
|
||||||
|
// Failed analysis jobs retain their place in diagnostics even without a file.
|
||||||
|
root := t.TempDir()
|
||||||
|
scanner := library.NewScanner(store, fakeProber{}, library.NewProgressBoard(), slog.New(slog.DiscardHandler))
|
||||||
|
tracks := map[uuid.UUID]bool{}
|
||||||
|
for _, name := range []string{"Artist - First.flac", "Artist - Second.flac"} {
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(root, name), []byte(name), 0o644))
|
||||||
|
fileID, err := scanner.ScanFile(ctx, src, root, name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var trackID uuid.UUID
|
||||||
|
require.NoError(t, pool.QueryRow(ctx, `SELECT track_id FROM media_files WHERE id = $1`, fileID).Scan(&trackID))
|
||||||
|
tracks[trackID] = true
|
||||||
|
_, err = pool.Exec(ctx, `UPDATE analysis_jobs SET status = 'failed', error = 'audio is shorter than 1 s' WHERE track_id = $1`, trackID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if name == "Artist - Second.flac" {
|
||||||
|
_, err = pool.Exec(ctx, `DELETE FROM media_files WHERE id = $1`, fileID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var paths, missing int
|
||||||
|
after := uuid.Nil
|
||||||
|
for {
|
||||||
|
page, err := store.AnalysisErrors(ctx, after, 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if len(page) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
item := page[0]
|
||||||
|
require.NotEqual(t, after, item.TrackID, "pagination must advance")
|
||||||
|
after = item.TrackID
|
||||||
|
if !tracks[item.TrackID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
require.Equal(t, "audio is shorter than 1 s", item.Error)
|
||||||
|
if item.RelPath == nil {
|
||||||
|
missing++
|
||||||
|
} else {
|
||||||
|
paths++
|
||||||
|
require.Equal(t, "Artist - First.flac", *item.RelPath)
|
||||||
|
require.Equal(t, src.ID, *item.SourceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.Equal(t, 1, paths)
|
||||||
|
require.Equal(t, 1, missing)
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"pipu/api/internal/api/gen"
|
||||||
|
"pipu/api/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type analysisErrorStore struct {
|
||||||
|
SourceStore
|
||||||
|
rows []AnalysisError
|
||||||
|
after uuid.UUID
|
||||||
|
limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *analysisErrorStore) AnalysisErrors(_ context.Context, after uuid.UUID, limit int) ([]AnalysisError, error) {
|
||||||
|
s.after, s.limit = after, limit
|
||||||
|
return s.rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalysisErrorsPermissionsAndPagination(t *testing.T) {
|
||||||
|
first, second := uuid.New(), uuid.New()
|
||||||
|
store := &analysisErrorStore{rows: []AnalysisError{{TrackID: first, Title: "One"}, {TrackID: second, Title: "Two"}}}
|
||||||
|
h := &Handler{svc: &Service{SourceService: &SourceService{store: store}}}
|
||||||
|
limit := 1
|
||||||
|
req := gen.ListAnalysisErrorsRequestObject{Params: gen.ListAnalysisErrorsParams{Limit: &limit}}
|
||||||
|
for _, role := range []auth.Role{auth.RoleUser, auth.RoleModerator} {
|
||||||
|
_, err := h.ListAnalysisErrors(auth.WithPrincipal(context.Background(), auth.Principal{Role: role}), req)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Zero(t, store.limit, "unauthorized requests must not reach the store")
|
||||||
|
}
|
||||||
|
_, err := h.ListAnalysisErrors(context.Background(), req)
|
||||||
|
require.Error(t, err)
|
||||||
|
ctx := auth.WithPrincipal(context.Background(), auth.Principal{Role: auth.RoleAdmin})
|
||||||
|
resp, err := h.ListAnalysisErrors(ctx, req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
page := resp.(gen.ListAnalysisErrors200JSONResponse)
|
||||||
|
require.Len(t, page.Items, 1)
|
||||||
|
require.Equal(t, first, page.Items[0].TrackId)
|
||||||
|
require.Nil(t, page.Items[0].RelPath, "a missing file must not hide the failed job")
|
||||||
|
require.Equal(t, 2, store.limit)
|
||||||
|
require.NotNil(t, page.NextCursor)
|
||||||
|
store.rows = store.rows[1:]
|
||||||
|
req.Params.Cursor = page.NextCursor
|
||||||
|
resp, err = h.ListAnalysisErrors(ctx, req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, first, store.after)
|
||||||
|
require.Nil(t, resp.(gen.ListAnalysisErrors200JSONResponse).NextCursor)
|
||||||
|
for _, cursor := range []string{"!invalid", base64.RawURLEncoding.EncodeToString([]byte("not-a-uuid"))} {
|
||||||
|
req.Params.Cursor = &cursor
|
||||||
|
_, err = h.ListAnalysisErrors(ctx, req)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"pipu/api/internal/api/gen"
|
"pipu/api/internal/api/gen"
|
||||||
"pipu/api/internal/library/meta"
|
"pipu/api/internal/library/meta"
|
||||||
"pipu/api/internal/platform/apperr"
|
"pipu/api/internal/platform/apperr"
|
||||||
@@ -80,7 +82,9 @@ func (h *Handler) sourceDTO(s Source) gen.Source {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func progressDTO(p Progress) gen.ScanProgress {
|
func progressDTO(p Progress) gen.ScanProgress {
|
||||||
|
phase := gen.ScanProgressPhase(p.Phase)
|
||||||
out := gen.ScanProgress{
|
out := gen.ScanProgress{
|
||||||
|
Phase: &phase, WalkedDirs: &p.WalkedDirs, CurrentDir: &p.CurrentDir,
|
||||||
Running: p.Running, Full: p.Full, StartedAt: p.StartedAt, FinishedAt: p.FinishedAt, Found: p.Found,
|
Running: p.Running, Full: p.Full, StartedAt: p.StartedAt, FinishedAt: p.FinishedAt, Found: p.Found,
|
||||||
ToProcess: p.ToProcess, Processed: p.Processed, Added: p.Added, Updated: p.Updated, Moved: p.Moved,
|
ToProcess: p.ToProcess, Processed: p.Processed, Added: p.Added, Updated: p.Updated, Moved: p.Moved,
|
||||||
Missing: p.Missing, Errors: p.Errors,
|
Missing: p.Missing, Errors: p.Errors,
|
||||||
@@ -365,6 +369,38 @@ func (h *Handler) ListLibraryErrors(ctx context.Context, req gen.ListLibraryErro
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListAnalysisErrors implements the paginated analysis diagnostics endpoint.
|
||||||
|
func (h *Handler) ListAnalysisErrors(ctx context.Context, req gen.ListAnalysisErrorsRequestObject) (gen.ListAnalysisErrorsResponseObject, error) {
|
||||||
|
limit := 50
|
||||||
|
if req.Params.Limit != nil {
|
||||||
|
limit = min(max(*req.Params.Limit, 1), 200)
|
||||||
|
}
|
||||||
|
after := uuid.Nil
|
||||||
|
if req.Params.Cursor != nil {
|
||||||
|
raw, err := base64.RawURLEncoding.DecodeString(*req.Params.Cursor)
|
||||||
|
if err == nil {
|
||||||
|
after, err = uuid.Parse(string(raw))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, apperr.Validation("cursor", "validation.cursor_invalid", "malformed cursor", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows, err := h.svc.AnalysisErrors(ctx, after, limit+1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := gen.ListAnalysisErrors200JSONResponse{Items: []gen.AnalysisError{}}
|
||||||
|
if len(rows) > limit {
|
||||||
|
rows = rows[:limit]
|
||||||
|
next := base64.RawURLEncoding.EncodeToString([]byte(rows[limit-1].TrackID.String()))
|
||||||
|
out.NextCursor = &next
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
out.Items = append(out.Items, gen.AnalysisError{TrackId: r.TrackID, Title: r.Title, SourceId: r.SourceID, SourceName: r.SourceName, RelPath: r.RelPath, Error: r.Error})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListHostDirs реализует gen.StrictServerInterface.
|
// ListHostDirs реализует gen.StrictServerInterface.
|
||||||
func (h *Handler) ListHostDirs(ctx context.Context, req gen.ListHostDirsRequestObject) (gen.ListHostDirsResponseObject, error) {
|
func (h *Handler) ListHostDirs(ctx context.Context, req gen.ListHostDirsRequestObject) (gen.ListHostDirsResponseObject, error) {
|
||||||
rel := ""
|
rel := ""
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ const MissingRetention = 30 * 24 * time.Hour
|
|||||||
|
|
||||||
// Progress — состояние скана одного источника (для админки).
|
// Progress — состояние скана одного источника (для админки).
|
||||||
type Progress struct {
|
type Progress struct {
|
||||||
|
Phase string
|
||||||
|
WalkedDirs int
|
||||||
|
CurrentDir string
|
||||||
SourceID uuid.UUID
|
SourceID uuid.UUID
|
||||||
Running bool
|
Running bool
|
||||||
Full bool
|
Full bool
|
||||||
@@ -82,7 +85,7 @@ func (b *ProgressBoard) start(id uuid.UUID, full bool, now time.Time) bool {
|
|||||||
if p, ok := b.m[id]; ok && p.Running {
|
if p, ok := b.m[id]; ok && p.Running {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
b.m[id] = &Progress{SourceID: id, Running: true, Full: full, StartedAt: now}
|
b.m[id] = &Progress{SourceID: id, Running: true, Full: full, StartedAt: now, Phase: "walking"}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,14 +205,18 @@ func (sc *Scanner) run(ctx context.Context, src Source, root string, full bool)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
found, err := walk(root, func(rel string) bool { return meta.MatchPath(patterns, rel).Skip })
|
found, err := walk(root, func(rel string) bool { return meta.MatchPath(patterns, rel).Skip }, func(found, dirs int, currentDir string) {
|
||||||
|
sc.board.update(src.ID, func(p *Progress) {
|
||||||
|
p.Found, p.WalkedDirs, p.CurrentDir = found, dirs, currentDir
|
||||||
|
})
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(found) == 0 && hasPresent(existing) {
|
if len(found) == 0 && hasPresent(existing) {
|
||||||
return ErrSourceEmpty
|
return ErrSourceEmpty
|
||||||
}
|
}
|
||||||
sc.board.update(src.ID, func(p *Progress) { p.Found = len(found) })
|
sc.board.update(src.ID, func(p *Progress) { p.Found, p.Phase = len(found), "preparing" })
|
||||||
|
|
||||||
var (
|
var (
|
||||||
jobs []job
|
jobs []job
|
||||||
@@ -256,11 +263,12 @@ func (sc *Scanner) run(ctx context.Context, src Source, root string, full bool)
|
|||||||
for _, w := range rest {
|
for _, w := range rest {
|
||||||
jobs = append(jobs, job{walked: w})
|
jobs = append(jobs, job{walked: w})
|
||||||
}
|
}
|
||||||
sc.board.update(src.ID, func(p *Progress) { p.ToProcess, p.Moved = len(jobs), len(moves) })
|
sc.board.update(src.ID, func(p *Progress) { p.ToProcess, p.Moved, p.Phase = len(jobs), len(moves), "processing" })
|
||||||
|
|
||||||
if err := sc.process(ctx, src, root, patterns, jobs); err != nil {
|
if err := sc.process(ctx, src, root, patterns, jobs); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
sc.board.update(src.ID, func(p *Progress) { p.Phase = "finalizing" })
|
||||||
|
|
||||||
var goneIDs []uuid.UUID
|
var goneIDs []uuid.UUID
|
||||||
affected := map[uuid.UUID]bool{}
|
affected := map[uuid.UUID]bool{}
|
||||||
@@ -462,11 +470,12 @@ func (sc *Scanner) apply(ctx context.Context, src Source, patterns []meta.Patter
|
|||||||
|
|
||||||
// walk обходит дерево источника и возвращает аудиофайлы. Скрытые файлы и каталоги пропускаются;
|
// walk обходит дерево источника и возвращает аудиофайлы. Скрытые файлы и каталоги пропускаются;
|
||||||
// skip — пути, которые шаблоны велят игнорировать. Ошибки отдельных каталогов не прерывают обход.
|
// skip — пути, которые шаблоны велят игнорировать. Ошибки отдельных каталогов не прерывают обход.
|
||||||
func walk(root string, skip func(rel string) bool) ([]walked, error) {
|
func walk(root string, skip func(rel string) bool, report func(found, dirs int, currentDir string)) ([]walked, error) {
|
||||||
if _, err := os.Stat(root); err != nil {
|
if _, err := os.Stat(root); err != nil {
|
||||||
return nil, fmt.Errorf("library: source root: %w", err)
|
return nil, fmt.Errorf("library: source root: %w", err)
|
||||||
}
|
}
|
||||||
var out []walked
|
var out []walked
|
||||||
|
dirs := 0
|
||||||
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if p == root {
|
if p == root {
|
||||||
@@ -481,14 +490,21 @@ func walk(root string, skip func(rel string) bool) ([]walked, error) {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if d.IsDir() || !IsAudio(name) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
rel, err := filepath.Rel(root, p)
|
rel, err := filepath.Rel(root, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
rel = filepath.ToSlash(rel)
|
rel = filepath.ToSlash(rel)
|
||||||
|
if d.IsDir() {
|
||||||
|
dirs++
|
||||||
|
if report != nil {
|
||||||
|
report(len(out), dirs, rel)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !IsAudio(name) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if skip(rel) {
|
if skip(rel) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -498,6 +514,9 @@ func walk(root string, skip func(rel string) bool) ([]walked, error) {
|
|||||||
}
|
}
|
||||||
// PostgreSQL хранит время с точностью до микросекунд.
|
// PostgreSQL хранит время с точностью до микросекунд.
|
||||||
out = append(out, walked{rel: rel, size: info.Size(), mtime: info.ModTime().UTC().Truncate(time.Microsecond)})
|
out = append(out, walked{rel: rel, size: info.Size(), mtime: info.ModTime().UTC().Truncate(time.Microsecond)})
|
||||||
|
if report != nil {
|
||||||
|
report(len(out), dirs, filepath.ToSlash(filepath.Dir(rel)))
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func TestWalkAndHash(t *testing.T) {
|
|||||||
write("_incoming/new.mp3", 10)
|
write("_incoming/new.mp3", 10)
|
||||||
write("big.mp3", 3<<20)
|
write("big.mp3", 3<<20)
|
||||||
|
|
||||||
found, err := walk(root, func(rel string) bool { return rel == "_incoming/new.mp3" })
|
found, err := walk(root, func(rel string) bool { return rel == "_incoming/new.mp3" }, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.ElementsMatch(t, []string{"Artist/Album/01 - Song.FLAC", "big.mp3"}, rels(found))
|
assert.ElementsMatch(t, []string{"Artist/Album/01 - Song.FLAC", "big.mp3"}, rels(found))
|
||||||
for _, w := range found {
|
for _, w := range found {
|
||||||
@@ -69,10 +69,42 @@ func TestWalkAndHash(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, h1, h2, "hash survives a rename")
|
assert.Equal(t, h1, h2, "hash survives a rename")
|
||||||
|
|
||||||
_, err = walk(filepath.Join(root, "nope"), func(string) bool { return false })
|
_, err = walk(filepath.Join(root, "nope"), func(string) bool { return false }, nil)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWalkReportsIncrementalProgress(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
for _, dir := range []string{"a-empty", "b-album", ".hidden"} {
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(root, dir), 0o755))
|
||||||
|
}
|
||||||
|
for _, rel := range []string{"b-album/1.mp3", "b-album/2.flac", "b-album/skip.mp3", ".hidden/3.mp3"} {
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(root, filepath.FromSlash(rel)), []byte("audio"), 0o644))
|
||||||
|
}
|
||||||
|
var counts, folders []int
|
||||||
|
var paths []string
|
||||||
|
found, err := walk(root, func(rel string) bool { return rel == "b-album/skip.mp3" }, func(n, dirs int, path string) {
|
||||||
|
counts, folders, paths = append(counts, n), append(folders, dirs), append(paths, path)
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, found, 2)
|
||||||
|
assert.Equal(t, []int{0, 0, 0, 1, 2}, counts, "reports activity before any file is found and after each accepted audio file")
|
||||||
|
assert.Equal(t, []int{1, 2, 3, 3, 3}, folders)
|
||||||
|
assert.Equal(t, []string{".", "a-empty", "b-album", "b-album", "b-album"}, paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgressDTOIncludesWalkActivity(t *testing.T) {
|
||||||
|
p := Progress{Phase: "walking", Running: true, Found: 7, WalkedDirs: 4, CurrentDir: "Artist/Album"}
|
||||||
|
dto := progressDTO(p)
|
||||||
|
require.NotNil(t, dto.Phase)
|
||||||
|
assert.Equal(t, "walking", string(*dto.Phase))
|
||||||
|
assert.Equal(t, p.Found, dto.Found)
|
||||||
|
require.NotNil(t, dto.WalkedDirs)
|
||||||
|
assert.Equal(t, p.WalkedDirs, *dto.WalkedDirs)
|
||||||
|
require.NotNil(t, dto.CurrentDir)
|
||||||
|
assert.Equal(t, p.CurrentDir, *dto.CurrentDir)
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseFFprobe(t *testing.T) {
|
func TestParseFFprobe(t *testing.T) {
|
||||||
raw := []byte(`{
|
raw := []byte(`{
|
||||||
"streams": [
|
"streams": [
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type SourceStore interface {
|
|||||||
AnalysisCounts(context.Context) (AnalysisCounts, error)
|
AnalysisCounts(context.Context) (AnalysisCounts, error)
|
||||||
WaveMetrics(context.Context) (WaveMetrics, error)
|
WaveMetrics(context.Context) (WaveMetrics, error)
|
||||||
FileErrors(context.Context, string, int) ([]FileError, error)
|
FileErrors(context.Context, string, int) ([]FileError, error)
|
||||||
|
AnalysisErrors(context.Context, uuid.UUID, int) ([]AnalysisError, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SourceService owns source configuration, connectivity and diagnostics.
|
// SourceService owns source configuration, connectivity and diagnostics.
|
||||||
@@ -440,6 +441,14 @@ func (s *SourceService) FileErrors(ctx context.Context, after string, limit int)
|
|||||||
return s.store.FileErrors(ctx, after, limit)
|
return s.store.FileErrors(ctx, after, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AnalysisErrors lists failed analysis jobs for library administrators.
|
||||||
|
func (s *SourceService) AnalysisErrors(ctx context.Context, after uuid.UUID, limit int) ([]AnalysisError, error) {
|
||||||
|
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.store.AnalysisErrors(ctx, after, limit)
|
||||||
|
}
|
||||||
|
|
||||||
// HostDirs — подпапки каталога LIBRARY_HOST для выбора local-хранилища.
|
// HostDirs — подпапки каталога LIBRARY_HOST для выбора local-хранилища.
|
||||||
func (s *SourceService) HostDirs(ctx context.Context, rel string) (string, []string, error) {
|
func (s *SourceService) HostDirs(ctx context.Context, rel string) (string, []string, error) {
|
||||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||||
|
|||||||
@@ -511,7 +511,7 @@ func (st *Store) Counts(ctx context.Context) (Counts, error) {
|
|||||||
err := st.pool.QueryRow(ctx, `
|
err := st.pool.QueryRow(ctx, `
|
||||||
SELECT (SELECT count(*) FROM artists), (SELECT count(*) FROM albums), (SELECT count(*) FROM tracks),
|
SELECT (SELECT count(*) FROM artists), (SELECT count(*) FROM albums), (SELECT count(*) FROM tracks),
|
||||||
(SELECT count(*) FROM media_files), (SELECT count(*) FROM media_files WHERE missing_since IS NOT NULL),
|
(SELECT count(*) FROM media_files), (SELECT count(*) FROM media_files WHERE missing_since IS NOT NULL),
|
||||||
(SELECT count(*) FROM media_files WHERE scan_error IS NOT NULL)`).
|
(SELECT count(*) FROM media_files WHERE scan_error IS NOT NULL AND missing_since IS NULL)`).
|
||||||
Scan(&c.Artists, &c.Albums, &c.Tracks, &c.Files, &c.Missing, &c.Errors)
|
Scan(&c.Artists, &c.Albums, &c.Tracks, &c.Files, &c.Missing, &c.Errors)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Counts{}, fmt.Errorf("library: counts: %w", err)
|
return Counts{}, fmt.Errorf("library: counts: %w", err)
|
||||||
@@ -531,7 +531,7 @@ type FileError struct {
|
|||||||
func (st *Store) FileErrors(ctx context.Context, after string, limit int) ([]FileError, error) {
|
func (st *Store) FileErrors(ctx context.Context, after string, limit int) ([]FileError, error) {
|
||||||
rows, err := st.pool.Query(ctx, `
|
rows, err := st.pool.Query(ctx, `
|
||||||
SELECT id, source_id, rel_path, scan_error FROM media_files
|
SELECT id, source_id, rel_path, scan_error FROM media_files
|
||||||
WHERE scan_error IS NOT NULL AND rel_path > $1 ORDER BY rel_path LIMIT $2`, after, limit)
|
WHERE scan_error IS NOT NULL AND missing_since IS NULL AND rel_path > $1 ORDER BY rel_path LIMIT $2`, after, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("library: file errors: %w", err)
|
return nil, fmt.Errorf("library: file errors: %w", err)
|
||||||
}
|
}
|
||||||
@@ -624,7 +624,38 @@ func (st *Store) AnalysisCounts(ctx context.Context) (AnalysisCounts, error) {
|
|||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RetryFailedAnalysis возвращает неудавшиеся задачи анализа в очередь с нуля попыток.
|
// AnalysisError is a failed analysis job with a known file path, when available.
|
||||||
|
type AnalysisError struct {
|
||||||
|
TrackID uuid.UUID
|
||||||
|
Title string
|
||||||
|
SourceID *uuid.UUID
|
||||||
|
SourceName *string
|
||||||
|
RelPath *string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnalysisErrors includes failed jobs even when their file is no longer available.
|
||||||
|
func (st *Store) AnalysisErrors(ctx context.Context, after uuid.UUID, limit int) ([]AnalysisError, error) {
|
||||||
|
rows, err := st.pool.Query(ctx, `
|
||||||
|
SELECT t.id, t.title, f.source_id, s.name, f.rel_path, COALESCE(a.error, '')
|
||||||
|
FROM analysis_jobs a JOIN tracks t ON t.id = a.track_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT mf.source_id, mf.rel_path FROM media_files mf WHERE mf.track_id = t.id
|
||||||
|
ORDER BY (mf.id = t.primary_file_id) DESC NULLS LAST, (mf.missing_since IS NULL) DESC, mf.id
|
||||||
|
LIMIT 1
|
||||||
|
) f ON true
|
||||||
|
LEFT JOIN library_sources s ON s.id = f.source_id
|
||||||
|
WHERE a.status = 'failed' AND t.id > $1 ORDER BY t.id LIMIT $2`, after, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("library: analysis errors: %w", err)
|
||||||
|
}
|
||||||
|
return pgx.CollectRows(rows, func(r pgx.CollectableRow) (AnalysisError, error) {
|
||||||
|
var e AnalysisError
|
||||||
|
return e, r.Scan(&e.TrackID, &e.Title, &e.SourceID, &e.SourceName, &e.RelPath, &e.Error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetryFailedAnalysis returns failed jobs to the queue with attempts reset.
|
||||||
func (st *Store) RetryFailedAnalysis(ctx context.Context) (int, error) {
|
func (st *Store) RetryFailedAnalysis(ctx context.Context) (int, error) {
|
||||||
tag, err := st.pool.Exec(ctx, `
|
tag, err := st.pool.Exec(ctx, `
|
||||||
UPDATE analysis_jobs SET status = 'queued', attempts = 0, error = NULL, updated_at = now()
|
UPDATE analysis_jobs SET status = 'queued', attempts = 0, error = NULL, updated_at = now()
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ func (s *TemplateService) Preview(ctx context.Context, id uuid.UUID, patterns []
|
|||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
if len(paths) == 0 {
|
if len(paths) == 0 {
|
||||||
found, err := walk(root, func(string) bool { return false })
|
found, err := walk(root, func(string) bool { return false }, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, apperr.New(http.StatusConflict, CodeSourceOffline, err.Error())
|
return nil, 0, apperr.New(http.StatusConflict, CodeSourceOffline, err.Error())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package playback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"pipu/api/internal/api/gen"
|
||||||
|
"pipu/api/internal/platform/apperr"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Repeated sharing preserves the existing URL. After revocation a fresh UUID is allocated.
|
||||||
|
func (st *Store) sharePlaylist(ctx context.Context, owner, id uuid.UUID) (uuid.UUID, error) {
|
||||||
|
var shareID uuid.UUID
|
||||||
|
err := st.pool.QueryRow(ctx, `UPDATE playlists SET share_id=COALESCE(share_id,$3)
|
||||||
|
WHERE id=$1 AND user_id=$2 RETURNING share_id`, id, owner, uuid.New()).Scan(&shareID)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return uuid.Nil, apperr.NotFound("playlist.not_found", "playlist not found")
|
||||||
|
}
|
||||||
|
return shareID, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *Store) revokePlaylistShare(ctx context.Context, owner, id uuid.UUID) error {
|
||||||
|
result, err := st.pool.Exec(ctx, `UPDATE playlists SET share_id=NULL WHERE id=$1 AND user_id=$2`, id, owner)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if result.RowsAffected() == 0 {
|
||||||
|
return apperr.NotFound("playlist.not_found", "playlist not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) SharePlaylist(ctx context.Context, req gen.SharePlaylistRequestObject) (gen.SharePlaylistResponseObject, error) {
|
||||||
|
user, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
shareID, err := h.store.sharePlaylist(ctx, user.UserID, req.Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.SharePlaylist200JSONResponse{ShareId: shareID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RevokePlaylistShare(ctx context.Context, req gen.RevokePlaylistShareRequestObject) (gen.RevokePlaylistShareResponseObject, error) {
|
||||||
|
user, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = h.store.revokePlaylistShare(ctx, user.UserID, req.Id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.RevokePlaylistShare204Response{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetSharedPlaylist(ctx context.Context, req gen.GetSharedPlaylistRequestObject) (gen.GetSharedPlaylistResponseObject, error) {
|
||||||
|
user, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p, err := h.readPlaylist(ctx, user.UserID, req.ShareId, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.GetSharedPlaylist200JSONResponse(p), nil
|
||||||
|
}
|
||||||
@@ -67,17 +67,33 @@ func (h *Handler) ListPlaylists(ctx context.Context, _ gen.ListPlaylistsRequestO
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) playlist(ctx context.Context, user, id uuid.UUID) (gen.Playlist, error) {
|
func (h *Handler) playlist(ctx context.Context, user, id uuid.UUID) (gen.Playlist, error) {
|
||||||
|
return h.readPlaylist(ctx, user, id, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) readPlaylist(ctx context.Context, user, id uuid.UUID, shared bool) (gen.Playlist, error) {
|
||||||
var out gen.Playlist
|
var out gen.Playlist
|
||||||
tx, err := h.store.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly})
|
tx, err := h.store.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx)
|
defer tx.Rollback(ctx)
|
||||||
p, err := scanPlaylist(tx.QueryRow(ctx, playlistSelect+` WHERE p.user_id=$1 AND p.id=$2`, user, id))
|
filter := ` WHERE p.user_id=$1 AND p.id=$2`
|
||||||
|
args := []any{user, id}
|
||||||
|
if shared {
|
||||||
|
filter = ` WHERE p.share_id=$1 AND EXISTS (SELECT 1 FROM users u WHERE u.id=p.user_id AND NOT u.is_blocked)`
|
||||||
|
args = []any{id}
|
||||||
|
}
|
||||||
|
p, err := scanPlaylist(tx.QueryRow(ctx, playlistSelect+filter, args...))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
rows, err := tx.Query(ctx, `SELECT track_id FROM playlist_tracks WHERE playlist_id=$1 ORDER BY position`, id)
|
var shareID *uuid.UUID
|
||||||
|
if !shared {
|
||||||
|
if err = tx.QueryRow(ctx, `SELECT share_id FROM playlists WHERE id=$1`, p.Id).Scan(&shareID); err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows, err := tx.Query(ctx, `SELECT track_id FROM playlist_tracks WHERE playlist_id=$1 ORDER BY position`, p.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
@@ -99,7 +115,7 @@ func (h *Handler) playlist(ctx context.Context, user, id uuid.UUID) (gen.Playlis
|
|||||||
if dtos == nil {
|
if dtos == nil {
|
||||||
dtos = []gen.Track{}
|
dtos = []gen.Track{}
|
||||||
}
|
}
|
||||||
out = gen.Playlist{Id: p.Id, Name: p.Name, Description: p.Description, Revision: p.Revision, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, TrackCount: len(dtos), Tracks: dtos}
|
out = gen.Playlist{ShareId: shareID, Id: p.Id, Name: p.Name, Description: p.Description, Revision: p.Revision, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, TrackCount: len(dtos), Tracks: dtos}
|
||||||
for _, t := range dtos {
|
for _, t := range dtos {
|
||||||
out.DurationMs += int64(t.DurationMs)
|
out.DurationMs += int64(t.DurationMs)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,40 @@ func TestPlaylistPersistenceAndIsolation(t *testing.T) {
|
|||||||
require.ErrorIs(t, st.savePlaylist(ctx, other, id, "stolen", "", nil, 1, false), apperr.NotFound("playlist.not_found", ""))
|
require.ErrorIs(t, st.savePlaylist(ctx, other, id, "stolen", "", nil, 1, false), apperr.NotFound("playlist.not_found", ""))
|
||||||
_, err = h.DeletePlaylist(auth.WithPrincipal(ctx, auth.Principal{UserID: other}), gen.DeletePlaylistRequestObject{Id: id})
|
_, err = h.DeletePlaylist(auth.WithPrincipal(ctx, auth.Principal{UserID: other}), gen.DeletePlaylistRequestObject{Id: id})
|
||||||
require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", ""))
|
require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", ""))
|
||||||
|
|
||||||
|
// Sharing grants read access through a separate opaque ID, never through the private owner endpoint.
|
||||||
|
ownerCtx := auth.WithPrincipal(ctx, auth.Principal{UserID: owner})
|
||||||
|
otherCtx := auth.WithPrincipal(ctx, auth.Principal{UserID: other})
|
||||||
|
_, err = h.GetSharedPlaylist(otherCtx, gen.GetSharedPlaylistRequestObject{ShareId: id})
|
||||||
|
require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", ""))
|
||||||
|
_, err = st.sharePlaylist(ctx, other, id)
|
||||||
|
require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", ""))
|
||||||
|
shareID, err := st.sharePlaylist(ctx, owner, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEqual(t, id, shareID)
|
||||||
|
sameID, err := st.sharePlaylist(ctx, owner, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, shareID, sameID)
|
||||||
|
response, err := h.GetSharedPlaylist(otherCtx, gen.GetSharedPlaylistRequestObject{ShareId: shareID})
|
||||||
|
require.NoError(t, err)
|
||||||
|
shared := gen.Playlist(response.(gen.GetSharedPlaylist200JSONResponse))
|
||||||
|
require.Equal(t, []uuid.UUID{first, second}, []uuid.UUID{shared.Tracks[0].Id, shared.Tracks[1].Id})
|
||||||
|
require.Nil(t, shared.ShareId, "the public DTO does not expose owner sharing controls")
|
||||||
|
got, err = h.playlist(ctx, owner, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, shareID, *got.ShareId)
|
||||||
|
require.Equal(t, 1, got.Revision, "sharing does not conflict with track editing")
|
||||||
|
_, err = h.GetPlaylist(otherCtx, gen.GetPlaylistRequestObject{Id: id})
|
||||||
|
require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", ""))
|
||||||
|
_, err = h.RevokePlaylistShare(otherCtx, gen.RevokePlaylistShareRequestObject{Id: id})
|
||||||
|
require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", ""))
|
||||||
|
_, err = h.RevokePlaylistShare(ownerCtx, gen.RevokePlaylistShareRequestObject{Id: id})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = h.GetSharedPlaylist(otherCtx, gen.GetSharedPlaylistRequestObject{ShareId: shareID})
|
||||||
|
require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", ""))
|
||||||
|
freshID, err := st.sharePlaylist(ctx, owner, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEqual(t, shareID, freshID, "revoked links never revive")
|
||||||
require.NoError(t, st.savePlaylist(ctx, owner, id, "Mix", "", []uuid.UUID{second, first}, 1, false))
|
require.NoError(t, st.savePlaylist(ctx, owner, id, "Mix", "", []uuid.UUID{second, first}, 1, false))
|
||||||
require.ErrorIs(t, st.savePlaylist(ctx, owner, id, "stale", "", nil, 1, false), apperr.Conflict("playlist.conflict", ""))
|
require.ErrorIs(t, st.savePlaylist(ctx, owner, id, "stale", "", nil, 1, false), apperr.Conflict("playlist.conflict", ""))
|
||||||
// A missing track rolls back both metadata and membership.
|
// A missing track rolls back both metadata and membership.
|
||||||
@@ -63,6 +97,16 @@ func TestPlaylistPersistenceAndIsolation(t *testing.T) {
|
|||||||
require.Equal(t, 2, got.Revision)
|
require.Equal(t, 2, got.Revision)
|
||||||
require.Equal(t, "Mix", got.Name)
|
require.Equal(t, "Mix", got.Name)
|
||||||
require.Equal(t, second, got.Tracks[0].Id)
|
require.Equal(t, second, got.Tracks[0].Id)
|
||||||
|
response, err = h.GetSharedPlaylist(otherCtx, gen.GetSharedPlaylistRequestObject{ShareId: freshID})
|
||||||
|
require.NoError(t, err)
|
||||||
|
shared = gen.Playlist(response.(gen.GetSharedPlaylist200JSONResponse))
|
||||||
|
require.Equal(t, second, shared.Tracks[0].Id, "shared pages follow edits to the live playlist")
|
||||||
|
_, err = pool.Exec(ctx, `UPDATE users SET is_blocked=true WHERE id=$1`, owner)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = h.GetSharedPlaylist(otherCtx, gen.GetSharedPlaylistRequestObject{ShareId: freshID})
|
||||||
|
require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", ""))
|
||||||
|
_, err = pool.Exec(ctx, `UPDATE users SET is_blocked=false WHERE id=$1`, owner)
|
||||||
|
require.NoError(t, err)
|
||||||
// Exactly one concurrent write may consume a revision.
|
// Exactly one concurrent write may consume a revision.
|
||||||
results := make(chan error, 2)
|
results := make(chan error, 2)
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
@@ -86,6 +130,8 @@ func TestPlaylistPersistenceAndIsolation(t *testing.T) {
|
|||||||
require.Equal(t, 1, successes)
|
require.Equal(t, 1, successes)
|
||||||
_, err = h.DeletePlaylist(auth.WithPrincipal(ctx, auth.Principal{UserID: owner}), gen.DeletePlaylistRequestObject{Id: id})
|
_, err = h.DeletePlaylist(auth.WithPrincipal(ctx, auth.Principal{UserID: owner}), gen.DeletePlaylistRequestObject{Id: id})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
_, err = h.GetSharedPlaylist(otherCtx, gen.GetSharedPlaylistRequestObject{ShareId: freshID})
|
||||||
|
require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", ""))
|
||||||
var remaining int
|
var remaining int
|
||||||
require.NoError(t, pool.QueryRow(ctx, `SELECT count(*) FROM playlist_tracks WHERE playlist_id=$1`, id).Scan(&remaining))
|
require.NoError(t, pool.QueryRow(ctx, `SELECT count(*) FROM playlist_tracks WHERE playlist_id=$1`, id).Scan(&remaining))
|
||||||
require.Zero(t, remaining)
|
require.Zero(t, remaining)
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package playback
|
package playback
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"pipu/api/internal/api/gen"
|
||||||
|
"pipu/api/internal/platform/apperr"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -19,3 +22,14 @@ func TestValidatePlaylist(t *testing.T) {
|
|||||||
require.Error(t, validatePlaylist("Mix", "", []uuid.UUID{uuid.Nil}))
|
require.Error(t, validatePlaylist("Mix", "", []uuid.UUID{uuid.Nil}))
|
||||||
require.Error(t, validatePlaylist("Mix", "", make([]uuid.UUID, 2001)))
|
require.Error(t, validatePlaylist("Mix", "", make([]uuid.UUID, 2001)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPlaylistSharingRequiresAuthentication(t *testing.T) {
|
||||||
|
h := &Handler{}
|
||||||
|
ctx := context.Background()
|
||||||
|
_, err := h.SharePlaylist(ctx, gen.SharePlaylistRequestObject{Id: uuid.New()})
|
||||||
|
require.ErrorIs(t, err, apperr.Unauthorized("auth.unauthorized", ""))
|
||||||
|
_, err = h.RevokePlaylistShare(ctx, gen.RevokePlaylistShareRequestObject{Id: uuid.New()})
|
||||||
|
require.ErrorIs(t, err, apperr.Unauthorized("auth.unauthorized", ""))
|
||||||
|
_, err = h.GetSharedPlaylist(ctx, gen.GetSharedPlaylistRequestObject{ShareId: uuid.New()})
|
||||||
|
require.ErrorIs(t, err, apperr.Unauthorized("auth.unauthorized", ""))
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE playlists ADD COLUMN share_id uuid;
|
||||||
|
CREATE UNIQUE INDEX playlists_share_id_idx ON playlists (share_id) WHERE share_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP INDEX playlists_share_id_idx;
|
||||||
|
ALTER TABLE playlists DROP COLUMN share_id;
|
||||||
Reference in new issue
Block a user