Compare commits

...
4 Commits
Author SHA1 Message Date
mrleo1nid 0dc0384c78 fix(library): expose scan activity and failed analysis file details
ci / api / api (push) Successful in 55s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m6s
ci / db / db (push) Successful in 52s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m38s
release / android (APK в релиз) (push) Successful in 3m50s
ci / ts / ts (packages + web) (push) Successful in 3m20s
release / release (версия и заметки) (push) Successful in 5s
release / desktop (Linux и Windows в релиз) (push) Successful in 44m45s
release / prune (старые файлы и прогоны) (push) Successful in 7s
2026-10-22 15:25:48 +03:00
mrleo1nid 17b6dd2041 fix(web): clarify Genius processing progress and queue actions
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m27s
ci / ts / ts (packages + web) (push) Successful in 5m23s
2026-10-22 15:05:11 +03:00
mrleo1nid 0a59059738 feat(playlists): share playlists with revocable read-only links
ci / api / api (push) Successful in 55s
release / release (версия и заметки) (push) Successful in 5s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m21s
ci / ts / ts (packages + web) (push) Successful in 2m19s
release / android (APK в релиз) (push) Successful in 4m45s
release / desktop (Linux и Windows в релиз) (push) Successful in 20m58s
release / prune (старые файлы и прогоны) (push) Successful in 9s
ci / module boundaries / boundaries (push) Successful in 7s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m35s
ci / db / db (push) Successful in 59s
2026-10-22 14:57:14 +03:00
mrleo1nid bfcf6e48b2 fix: harmonize wave colors from album covers
ci / ts / ts (packages + web) (push) Canceled after 5m28s
ci / module boundaries / boundaries (push) Successful in 5s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m18s
2026-10-22 14:52:57 +03:00
49 changed files with 2170 additions and 364 deletions

No files matched your search

+119
View File
@@ -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]
+17 -4
View File
@@ -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}),[
+3 -3
View File
@@ -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));
+13
View File
@@ -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);
});
}
+53 -1
View File
@@ -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();
});
});
+2
View File
@@ -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,
+7 -4
View File
@@ -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 } {
+1 -5
View File
@@ -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')}
+32 -9
View File
@@ -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']);
});
+12 -3
View File
@@ -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>;
}
+12 -2
View File
@@ -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 && (
<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();
});
+11 -3
View File
@@ -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
View File
@@ -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; }
+33 -1
View File
@@ -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(); }
});
});
+10 -2
View File
@@ -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 });
+21 -3
View File
@@ -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);
}
+6 -6
View File
@@ -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;
+6
View File
@@ -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 использует отдельные адаптивный цветной
+13
View File
@@ -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
View File
@@ -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 и названием плейлиста.
## Проверки
+181
View File
@@ -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"];
};
};
}
+2 -2
View File
@@ -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';
+6 -1
View File
@@ -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');
});
+5
View File
@@ -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;
}
+50
View File
@@ -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);
}
});
});
+33
View File
@@ -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,
+14 -2
View File
@@ -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",
+12 -2
View File
@@ -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",
+14 -2
View File
@@ -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": "Приём загрузок и шаблон пути",
+12 -2
View File
@@ -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)
}
}
+36
View File
@@ -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 := ""
+27 -8
View File
@@ -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 {
+34 -2
View File
@@ -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": [
+9
View File
@@ -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 {
+34 -3
View File
@@ -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()
+1 -1
View File
@@ -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
}
+19 -3
View File
@@ -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;