Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dc0384c78 | ||
|
|
17b6dd2041 | ||
|
|
0a59059738 | ||
|
|
bfcf6e48b2 |
No files matched your search
@@ -991,6 +991,23 @@ paths:
|
||||
$ref: "#/components/schemas/LibraryErrorPage"
|
||||
default:
|
||||
$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:
|
||||
get:
|
||||
operationId: listHostDirs
|
||||
@@ -1707,6 +1724,54 @@ paths:
|
||||
'204':
|
||||
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:
|
||||
parameters:
|
||||
SourceId:
|
||||
@@ -1767,6 +1832,11 @@ components:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
schemas:
|
||||
PlaylistShare:
|
||||
type: object
|
||||
required: [share_id]
|
||||
properties:
|
||||
share_id: { type: string, format: uuid }
|
||||
PlaylistInput:
|
||||
type: object
|
||||
required: [name, description, track_ids]
|
||||
@@ -1803,6 +1873,12 @@ components:
|
||||
- type: object
|
||||
required: [tracks]
|
||||
properties:
|
||||
share_id:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
readOnly: true
|
||||
description: Действующая ссылка; возвращается только владельцу через /me/playlists
|
||||
tracks:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/Track' }
|
||||
@@ -2896,6 +2972,16 @@ components:
|
||||
type: object
|
||||
required: [running, full, started_at, finished_at, found, to_process, processed, added, updated, moved, missing, errors, error]
|
||||
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:
|
||||
type: boolean
|
||||
full:
|
||||
@@ -2997,6 +3083,39 @@ components:
|
||||
next_cursor:
|
||||
type: string
|
||||
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:
|
||||
type: object
|
||||
required: [path, dirs]
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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 { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, FlatList, Modal, ScrollView, View } from 'react-native';
|
||||
import { useSession, useUser } from '../lib/session';
|
||||
import { Alert, FlatList, Modal, ScrollView, Share, View } from 'react-native';
|
||||
import { useSession, useSessionCtx, useUser } from '../lib/session';
|
||||
import { player } from '../player/controller';
|
||||
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 }) {
|
||||
const session = useSession();
|
||||
const { server } = useSessionCtx();
|
||||
const user = useUser();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
@@ -89,6 +90,15 @@ function Detail({ id }: { id: string }) {
|
||||
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 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 } })),
|
||||
onSuccess: data => { cache.setQueryData(key, data); 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}
|
||||
ListHeaderComponent={<View style={{ padding:16,gap:12 }}>
|
||||
<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>}
|
||||
{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.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.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}),[
|
||||
|
||||
@@ -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 { 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 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 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 state = useSharedValue<WaveState>(initialWaveState(size));
|
||||
|
||||
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
|
||||
for (const width of [320, 1280]) {
|
||||
test(`playlists at ${width}px`, async ({ page }, info) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||||
await page.goto('/login');
|
||||
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('button', { name: 'Управлять треками' }).click();
|
||||
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(() => {
|
||||
const main = document.querySelector('main')!;
|
||||
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
|
||||
})).toBeLessThanOrEqual(1);
|
||||
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 { createAppRouter, safeRedirect } from './app/router';
|
||||
import { fakeApi, makeUser, type FakeUser } from './test/fakeApi';
|
||||
import { albumTracks, fakeApi, makeUser, type FakeUser } from './test/fakeApi';
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
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 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 playlistRoute = createRoute({ getParentRoute: () => appRoute, path: '/playlist/$id', component: () => <PlaylistPage id={playlistRoute.useParams().id} /> });
|
||||
|
||||
@@ -280,6 +281,7 @@ const routeTree = rootRoute.addChildren([
|
||||
homeRoute,
|
||||
trackRoute,
|
||||
playlistsRoute,
|
||||
sharedPlaylistRoute,
|
||||
playlistRoute,
|
||||
settingsRoute,
|
||||
searchRoute,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSession } from '../app/session';
|
||||
import { useSession, useUser } from '../app/session';
|
||||
import { useErrorText } from '../shared/lib/errors';
|
||||
import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui';
|
||||
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')) });
|
||||
}
|
||||
|
||||
export function usePlaylist(id: string) {
|
||||
export function usePlaylist(id: string, shared = false) {
|
||||
const session = useSession();
|
||||
return useQuery({ queryKey: ['playlists', id],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
|
||||
const user = useUser();
|
||||
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 } {
|
||||
|
||||
@@ -41,8 +41,6 @@ const ICONS: Record<string, string> = {
|
||||
rainy: waveRainyIcon,
|
||||
};
|
||||
|
||||
const WAVE_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
|
||||
|
||||
type Tr = (key: string) => string;
|
||||
|
||||
/** Главная: для нового пользователя без истории — сначала онбординг вкуса, затем волна. */
|
||||
@@ -97,8 +95,6 @@ function WaveHome() {
|
||||
const waveOn = st.wave !== null;
|
||||
const playing = waveOn && st.playing;
|
||||
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) => {
|
||||
setError(null);
|
||||
@@ -129,7 +125,7 @@ function WaveHome() {
|
||||
readAudio={player.readAudio}
|
||||
bpm={track?.features?.bpm ?? undefined}
|
||||
energy={track?.features?.energy ?? undefined}
|
||||
colors={colors}
|
||||
colors={track?.album.cover_colors}
|
||||
theme={document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'}
|
||||
size={320}
|
||||
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 { usePlayer } from '../player/context';
|
||||
import { useErrorText } from '../shared/lib/errors';
|
||||
import { sharePlaylist } from '../shared/lib/share';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
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 s from './page.module.css';
|
||||
import p from './playlists.module.css';
|
||||
@@ -42,19 +43,19 @@ export function PlaylistsPage() {
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function PlaylistPage({ id }: { id: string }) {
|
||||
export function PlaylistPage({ id, shared = false }: { id: string; shared?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const query = usePlaylist(id);
|
||||
const query = usePlaylist(id, shared);
|
||||
const errorText = useErrorText();
|
||||
return <div className={s.page}>
|
||||
<Link to="/collection/playlists">← {t('playlists.title')}</Link>
|
||||
<FormError>{query.error ? errorText(query.error) : null}</FormError>
|
||||
{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>;
|
||||
}
|
||||
|
||||
function PlaylistDetail({ playlist }: { playlist: Playlist }) {
|
||||
function PlaylistDetail({ playlist, readOnly = false, shareId }: { playlist: Playlist; readOnly?: boolean; shareId?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const cache = useQueryClient();
|
||||
@@ -64,6 +65,21 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
|
||||
const [edit, setEdit] = useState(false);
|
||||
const [remove, setRemove] = 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 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 },
|
||||
@@ -84,7 +100,7 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
|
||||
return <>
|
||||
<header className={c.colHero}>
|
||||
<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>
|
||||
<span>{t('catalog.track_count', { count: playlist.track_count })} · {formatDuration(playlist.duration_ms)}</span>
|
||||
</div>
|
||||
@@ -92,10 +108,17 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
|
||||
<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 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 aria-pressed={manage} onClick={() => setManage(!manage)}>{t('playlists.manage')}</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>
|
||||
{!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>
|
||||
{!playlist.tracks.length && (
|
||||
<EmptyState icon={playlistAddIcon} action={<Link to="/search" search={{ q: '' }}>{t('playlists.find_tracks')}</Link>}>
|
||||
@@ -103,13 +126,13 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
|
||||
</EmptyState>
|
||||
)}
|
||||
<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 === 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>
|
||||
</span> : undefined} />
|
||||
{edit && <PlaylistEditor playlist={playlist} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}
|
||||
{remove && (
|
||||
{!readOnly && edit && <PlaylistEditor playlist={playlist} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}
|
||||
{!readOnly && remove && (
|
||||
<Dialog
|
||||
title={t('playlists.delete')}
|
||||
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';
|
||||
|
||||
export function GeniusPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const session = useSession();
|
||||
const queries = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
@@ -24,16 +24,23 @@ export function GeniusPanel() {
|
||||
});
|
||||
const data = stats.data;
|
||||
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">
|
||||
<div className={s.head}><h2>Genius</h2><span role="status">{state}</span></div>
|
||||
<p className={s.subtitle}>{t('genius.description')}</p>
|
||||
<FormError>{stats.error ? errorText(stats.error) : control.error ? errorText(control.error) : null}</FormError>
|
||||
{data && <>
|
||||
{!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}>
|
||||
{(['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>
|
||||
{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>}
|
||||
@@ -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 || data.errors + data.unmatched === 0} onClick={() => control.mutate('retry')}>{t('genius.retry')}</Button>
|
||||
</div>
|
||||
<p className={s.subtitle}>{t('genius.controls_hint')}</p>
|
||||
<p className={s.subtitle}>{t('genius.retry_hint')}</p>
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useId } from 'react';
|
||||
import { useId, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession, useUser } from '../../app/session';
|
||||
@@ -10,6 +10,7 @@ import { Button, FormError } from '../../shared/ui/ui';
|
||||
import s from '../page.module.css';
|
||||
import l from './library.module.css';
|
||||
import { ScanLine, StatusBadge } from './ScanStatus';
|
||||
import { AnalysisErrorsDialog } from './AnalysisErrorsDialog';
|
||||
|
||||
/** Состояние библиотеки: счётчики, хранилища и сканы (admin, moderator), ошибки чтения (admin). */
|
||||
export function LibraryPage() {
|
||||
@@ -18,6 +19,7 @@ export function LibraryPage() {
|
||||
const user = useUser();
|
||||
const errorText = useErrorText();
|
||||
const admin = user.role === 'admin';
|
||||
const [showAnalysisErrors, setShowAnalysisErrors] = useState(false);
|
||||
|
||||
const status = useQuery({
|
||||
queryKey: ['library-status'],
|
||||
@@ -27,6 +29,7 @@ export function LibraryPage() {
|
||||
const errors = useQuery({
|
||||
queryKey: ['library-errors'],
|
||||
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 } } })),
|
||||
});
|
||||
const reindex = useMutation({
|
||||
@@ -35,7 +38,10 @@ export function LibraryPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const retry = useMutation({
|
||||
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 tiles = d
|
||||
@@ -89,13 +95,17 @@ export function LibraryPage() {
|
||||
{d.analysis.top_error && <span className={l.errorDetail}>{d.analysis.top_error}</span>}
|
||||
</span>
|
||||
{admin && (
|
||||
<Button variant="secondary" icon={refreshIcon} busy={retry.isPending} onClick={() => retry.mutate()}>
|
||||
{t('library.analysis_retry')}
|
||||
</Button>
|
||||
<div className={l.analysisActions}>
|
||||
<Button onClick={() => setShowAnalysisErrors(true)}>{t('library.analysis_show_errors')}</Button>
|
||||
<Button variant="secondary" icon={refreshIcon} busy={retry.isPending} onClick={() => retry.mutate()}>
|
||||
{t('library.analysis_retry')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{d.analysis.analyzer_ver && <span className={l.scanSummary}>{t('library.analyzer', { ver: d.analysis.analyzer_ver })}</span>}
|
||||
{showAnalysisErrors && <AnalysisErrorsDialog onClose={() => setShowAnalysisErrors(false)} />}
|
||||
</section>
|
||||
)}
|
||||
{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 id = useId();
|
||||
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 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 (
|
||||
<div className={s.scan}>
|
||||
<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 className={s.bar} role="progressbar" aria-labelledby={id} aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}>
|
||||
<span style={{ width: `${pct}%` }} />
|
||||
{phase === 'walking' && scan.current_dir && <div className={s.scanPath} title={scan.current_dir}>{t('sources.scan_current_dir', { path: scan.current_dir })}</div>}
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -123,8 +123,14 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
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 {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
@@ -146,6 +152,26 @@
|
||||
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 {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
.stats dt { color: var(--text2); font-size: 13px; }
|
||||
.stats dd { margin: 6px 0 0; font-size: 25px; font-variant-numeric: tabular-nums; }
|
||||
.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; }
|
||||
.annotation { border-top: 1px solid var(--line); padding-top: 16px; overflow-wrap: anywhere; }
|
||||
.annotation summary { cursor: pointer; line-height: 1.6; }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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());
|
||||
|
||||
@@ -38,3 +39,34 @@ describe('shareTrack', () => {
|
||||
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. */
|
||||
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) {
|
||||
try {
|
||||
await navigator.share({ title, url });
|
||||
|
||||
@@ -76,6 +76,7 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
let token = 0;
|
||||
const calls: { method: string; path: string; body: unknown }[] = [];
|
||||
const reactions = new Map<string, 'like' | 'dislike'>();
|
||||
const playlistOwners = new Map<string, string>();
|
||||
const playlists = new Map<string, components['schemas']['Playlist']>();
|
||||
|
||||
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 (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);
|
||||
if (path === '/api/v1/me/playlists' && req.method === 'POST' || playlistPath) {
|
||||
const id = playlistPath?.[1] ?? `00000000-0000-4000-9000-${String(++seq).padStart(12, '0')}`;
|
||||
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 === 'DELETE') { playlists.delete(id); return new Response(null, { status: 204 }); }
|
||||
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 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),
|
||||
revision: (old?.revision ?? 0) + 1, created_at: old?.created_at ?? new Date().toISOString(), updated_at: new Date().toISOString() };
|
||||
playlists.set(id, item);
|
||||
if (!old) playlistOwners.set(id, user.id);
|
||||
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';
|
||||
|
||||
@@ -8,7 +9,7 @@ export interface WaveButtonProps {
|
||||
readAudio?: () => { bass: number; mid: number; treble: number };
|
||||
bpm?: number;
|
||||
energy?: number;
|
||||
colors?: [string, string, string];
|
||||
colors?: readonly string[];
|
||||
theme?: 'dark' | 'light';
|
||||
interactive?: boolean;
|
||||
size?: number;
|
||||
@@ -17,8 +18,6 @@ export interface WaveButtonProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const DEFAULT_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
@@ -29,6 +28,7 @@ function prefersReducedMotion(): boolean {
|
||||
*/
|
||||
export function WaveButton(props: WaveButtonProps) {
|
||||
const size = props.size ?? 320;
|
||||
const colors = useMemo(() => wavePalette(props.colors), [props.colors]);
|
||||
const audioRef = useRef(props.readAudio);
|
||||
audioRef.current = props.readAudio;
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
@@ -36,7 +36,7 @@ export function WaveButton(props: WaveButtonProps) {
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
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,
|
||||
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,
|
||||
});
|
||||
// Пропсы читаются в цикле анимации через ref — перерисовка React не нужна.
|
||||
@@ -45,7 +45,7 @@ export function WaveButton(props: WaveButtonProps) {
|
||||
s.playing = !!props.playing;
|
||||
s.bpm = props.bpm ?? 100;
|
||||
s.energy = props.energy ?? 0.6;
|
||||
s.colors = props.colors ?? DEFAULT_COLORS;
|
||||
s.colors = colors;
|
||||
s.dark = props.theme !== 'light';
|
||||
s.size = size;
|
||||
|
||||
|
||||
@@ -45,6 +45,12 @@
|
||||
|
||||
Палитра волны по умолчанию: `#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`; он же используется в шапке веб-приложения.
|
||||
Команда `pnpm icons` пересобирает PNG/ICO/ICNS для desktop, favicon PNG, Apple touch icon
|
||||
и Android-ресурсы в `apps/mobile/assets/`. Android использует отдельные адаптивный цветной
|
||||
|
||||
@@ -139,6 +139,19 @@ lower-case, `ё → е`, удаление пунктуации, схлопыва
|
||||
|
||||
Ожидаемое время первого скана 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.*` в папке альбома → плейсхолдер,
|
||||
|
||||
+21
-2
@@ -1,6 +1,6 @@
|
||||
# Личные плейлисты
|
||||
|
||||
Плейлисты хранятся в PostgreSQL и доступны владельцу на вебе и в React Native-клиенте. В вебе раздел находится в коллекции; на узком экране — в меню «Ещё». В мобильном клиенте — кнопка «Плейлисты» в коллекции.
|
||||
Плейлисты хранятся в PostgreSQL. Владелец управляет ими в вебе, desktop и React Native-клиенте; по желанию может поделиться ссылкой для прослушивания. В вебе раздел находится в коллекции; на узком экране — в меню «Ещё». В мобильном клиенте — кнопка «Плейлисты» в коллекции.
|
||||
|
||||
Можно создать плейлист, изменить название и описание, добавить трек из поиска, альбома или коллекции, убрать трек и переместить его выше/ниже. В вебе можно добавить целый альбом. Один трек встречается в плейлисте один раз: повторное добавление не создаёт дубль. Воспроизведение доступно в сохранённом или случайном порядке; недоступные треки остаются видимыми, но пропускаются при запуске всего плейлиста. Удаление плейлиста требует подтверждения и не удаляет музыку из каталога.
|
||||
|
||||
@@ -11,12 +11,31 @@
|
||||
- `GET /api/v1/me/playlists/{id}` — метаданные и упорядоченные треки с персональными реакциями и подписанными ссылками.
|
||||
- `PUT /api/v1/me/playlists/{id}` — атомарная замена метаданных и порядка треков; дополнительно требуется `revision` из прочитанного плейлиста.
|
||||
- `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 уникальными треками.
|
||||
|
||||
Миграция `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;
|
||||
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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1439,10 +1456,53 @@ export interface paths {
|
||||
patch?: 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 interface components {
|
||||
schemas: {
|
||||
PlaylistShare: {
|
||||
/** Format: uuid */
|
||||
share_id: string;
|
||||
};
|
||||
PlaylistInput: {
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -1466,6 +1526,11 @@ export interface components {
|
||||
updated_at: string;
|
||||
};
|
||||
Playlist: components["schemas"]["PlaylistSummary"] & {
|
||||
/**
|
||||
* Format: uuid
|
||||
* @description Действующая ссылка; возвращается только владельцу через /me/playlists
|
||||
*/
|
||||
readonly share_id?: string | null;
|
||||
tracks: components["schemas"]["Track"][];
|
||||
};
|
||||
PlaylistList: {
|
||||
@@ -2030,6 +2095,15 @@ export interface components {
|
||||
full?: boolean;
|
||||
};
|
||||
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;
|
||||
full: boolean;
|
||||
/** Format: date-time */
|
||||
@@ -2079,6 +2153,21 @@ export interface components {
|
||||
items: components["schemas"]["LibraryError"][];
|
||||
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: {
|
||||
path: string;
|
||||
dirs: string[];
|
||||
@@ -3712,6 +3801,31 @@ export interface operations {
|
||||
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: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@@ -4607,4 +4721,71 @@ export interface operations {
|
||||
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,
|
||||
} from './player';
|
||||
export { normalizeGain, TARGET_LUFS } from './gain';
|
||||
export { trackShareUrl } from './share';
|
||||
export { playlistShareUrl, trackShareUrl } from './share';
|
||||
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';
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { trackShareUrl } from './share';
|
||||
import { playlistShareUrl, trackShareUrl } from './share';
|
||||
|
||||
describe('trackShareUrl', () => {
|
||||
it('builds a page URL without stream credentials', () => {
|
||||
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 {
|
||||
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'];
|
||||
|
||||
/** 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 {
|
||||
return {
|
||||
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.",
|
||||
"scanning": "Scanning: {{processed}} of {{total}}",
|
||||
"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}}",
|
||||
"test_ok": "Reachable, audio files found: {{count}}",
|
||||
"test_fail": "Not reachable: {{message}}",
|
||||
@@ -172,7 +176,15 @@
|
||||
"wave_likes": "Likes per 100",
|
||||
"wave_model": "Model version",
|
||||
"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": {
|
||||
"title": "Uploads and path template",
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
{
|
||||
"genius": {
|
||||
"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.",
|
||||
"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",
|
||||
"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": {
|
||||
"name": "pipu",
|
||||
@@ -340,6 +345,11 @@
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"delete_confirm": "Delete playlist “{{name}}”? Tracks will remain in your library.",
|
||||
"manage": "Manage tracks",
|
||||
|
||||
@@ -98,7 +98,11 @@
|
||||
"delete_confirm": "Удалить «{{name}}»? Треки, которые есть только в этом хранилище, пропадут из библиотеки вместе с лайками.",
|
||||
"scanning": "Сканирование: {{processed}} из {{total}}",
|
||||
"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}}",
|
||||
"test_ok": "Доступно, найдено аудиофайлов: {{count}}",
|
||||
"test_fail": "Нет доступа: {{message}}",
|
||||
@@ -172,7 +176,15 @@
|
||||
"wave_likes": "Лайков на 100",
|
||||
"wave_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": {
|
||||
"title": "Приём загрузок и шаблон пути",
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
{
|
||||
"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.",
|
||||
"total": "Всего треков", "pending": "В очереди", "matched": "Найдено в Genius", "lyrics": "Тексты", "annotations": "С пояснениями", "images": "С картинками", "unmatched": "Нет точного совпадения", "errors": "Ошибки / неполные данные",
|
||||
"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": {
|
||||
"name": "pipu",
|
||||
@@ -340,6 +345,11 @@
|
||||
}
|
||||
},
|
||||
"playlists": {
|
||||
"share": "Поделиться плейлистом",
|
||||
"link_copied": "Ссылка на плейлист скопирована",
|
||||
"shared": "Доступ по ссылке",
|
||||
"revoke_share": "Отключить доступ по ссылке",
|
||||
"share_hint": "Любой пользователь этого сервера со ссылкой сможет слушать плейлист. Изменять его можете только вы.",
|
||||
"name": "Название",
|
||||
"delete_confirm": "Удалить плейлист «{{name}}»? Треки останутся в библиотеке.",
|
||||
"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"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"pipu/api/internal/api/gen"
|
||||
"pipu/api/internal/library/meta"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
@@ -80,7 +82,9 @@ func (h *Handler) sourceDTO(s Source) gen.Source {
|
||||
}
|
||||
|
||||
func progressDTO(p Progress) gen.ScanProgress {
|
||||
phase := gen.ScanProgressPhase(p.Phase)
|
||||
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,
|
||||
ToProcess: p.ToProcess, Processed: p.Processed, Added: p.Added, Updated: p.Updated, Moved: p.Moved,
|
||||
Missing: p.Missing, Errors: p.Errors,
|
||||
@@ -365,6 +369,38 @@ func (h *Handler) ListLibraryErrors(ctx context.Context, req gen.ListLibraryErro
|
||||
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.
|
||||
func (h *Handler) ListHostDirs(ctx context.Context, req gen.ListHostDirsRequestObject) (gen.ListHostDirsResponseObject, error) {
|
||||
rel := ""
|
||||
|
||||
@@ -28,6 +28,9 @@ const MissingRetention = 30 * 24 * time.Hour
|
||||
|
||||
// Progress — состояние скана одного источника (для админки).
|
||||
type Progress struct {
|
||||
Phase string
|
||||
WalkedDirs int
|
||||
CurrentDir string
|
||||
SourceID uuid.UUID
|
||||
Running 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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -202,14 +205,18 @@ func (sc *Scanner) run(ctx context.Context, src Source, root string, full bool)
|
||||
if err != nil {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
if len(found) == 0 && hasPresent(existing) {
|
||||
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 (
|
||||
jobs []job
|
||||
@@ -256,11 +263,12 @@ func (sc *Scanner) run(ctx context.Context, src Source, root string, full bool)
|
||||
for _, w := range rest {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
sc.board.update(src.ID, func(p *Progress) { p.Phase = "finalizing" })
|
||||
|
||||
var goneIDs []uuid.UUID
|
||||
affected := map[uuid.UUID]bool{}
|
||||
@@ -462,11 +470,12 @@ func (sc *Scanner) apply(ctx context.Context, src Source, patterns []meta.Patter
|
||||
|
||||
// walk обходит дерево источника и возвращает аудиофайлы. Скрытые файлы и каталоги пропускаются;
|
||||
// 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 {
|
||||
return nil, fmt.Errorf("library: source root: %w", err)
|
||||
}
|
||||
var out []walked
|
||||
dirs := 0
|
||||
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
if p == root {
|
||||
@@ -481,14 +490,21 @@ func walk(root string, skip func(rel string) bool) ([]walked, error) {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() || !IsAudio(name) {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(root, p)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
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) {
|
||||
return nil
|
||||
}
|
||||
@@ -498,6 +514,9 @@ func walk(root string, skip func(rel string) bool) ([]walked, error) {
|
||||
}
|
||||
// PostgreSQL хранит время с точностью до микросекунд.
|
||||
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
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -55,7 +55,7 @@ func TestWalkAndHash(t *testing.T) {
|
||||
write("_incoming/new.mp3", 10)
|
||||
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)
|
||||
assert.ElementsMatch(t, []string{"Artist/Album/01 - Song.FLAC", "big.mp3"}, rels(found))
|
||||
for _, w := range found {
|
||||
@@ -69,10 +69,42 @@ func TestWalkAndHash(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
raw := []byte(`{
|
||||
"streams": [
|
||||
|
||||
@@ -33,6 +33,7 @@ type SourceStore interface {
|
||||
AnalysisCounts(context.Context) (AnalysisCounts, error)
|
||||
WaveMetrics(context.Context) (WaveMetrics, error)
|
||||
FileErrors(context.Context, string, int) ([]FileError, error)
|
||||
AnalysisErrors(context.Context, uuid.UUID, int) ([]AnalysisError, error)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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-хранилища.
|
||||
func (s *SourceService) HostDirs(ctx context.Context, rel string) (string, []string, error) {
|
||||
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, `
|
||||
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 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)
|
||||
if err != nil {
|
||||
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) {
|
||||
rows, err := st.pool.Query(ctx, `
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
tag, err := st.pool.Exec(ctx, `
|
||||
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
|
||||
}
|
||||
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 {
|
||||
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) {
|
||||
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
|
||||
tx, err := h.store.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly})
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
return out, err
|
||||
}
|
||||
@@ -99,7 +115,7 @@ func (h *Handler) playlist(ctx context.Context, user, id uuid.UUID) (gen.Playlis
|
||||
if dtos == nil {
|
||||
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 {
|
||||
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", ""))
|
||||
_, err = h.DeletePlaylist(auth.WithPrincipal(ctx, auth.Principal{UserID: other}), gen.DeletePlaylistRequestObject{Id: id})
|
||||
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.ErrorIs(t, st.savePlaylist(ctx, owner, id, "stale", "", nil, 1, false), apperr.Conflict("playlist.conflict", ""))
|
||||
// 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, "Mix", got.Name)
|
||||
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.
|
||||
results := make(chan error, 2)
|
||||
var wg sync.WaitGroup
|
||||
@@ -86,6 +130,8 @@ func TestPlaylistPersistenceAndIsolation(t *testing.T) {
|
||||
require.Equal(t, 1, successes)
|
||||
_, err = h.DeletePlaylist(auth.WithPrincipal(ctx, auth.Principal{UserID: owner}), gen.DeletePlaylistRequestObject{Id: id})
|
||||
require.NoError(t, err)
|
||||
_, err = h.GetSharedPlaylist(otherCtx, gen.GetSharedPlaylistRequestObject{ShareId: freshID})
|
||||
require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", ""))
|
||||
var remaining int
|
||||
require.NoError(t, pool.QueryRow(ctx, `SELECT count(*) FROM playlist_tracks WHERE playlist_id=$1`, id).Scan(&remaining))
|
||||
require.Zero(t, remaining)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
"pipu/api/internal/api/gen"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -19,3 +22,14 @@ func TestValidatePlaylist(t *testing.T) {
|
||||
require.Error(t, validatePlaylist("Mix", "", []uuid.UUID{uuid.Nil}))
|
||||
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