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

This commit is contained in:
mrleo1nid committed 2026-10-22 14:57:14 +03:00
1 parent bfcf6e48b2
commit 0a59059738
23 files changed
+1164 -319

No files matched your search

+59
View File
@@ -1707,6 +1707,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 +1815,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 +1856,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' }
+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}),[
+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 } {
+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={() => {
+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);
}
+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 и названием плейлиста.
## Проверки
+115
View File
@@ -1439,10 +1439,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 +1509,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: {
@@ -4607,4 +4655,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"];
};
};
}
+1 -1
View File
@@ -22,7 +22,7 @@ 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, wavePalette, waveScale, type Rgb, type WaveState } from './wave-anim';
+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;
}
+5
View File
@@ -340,6 +340,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",
+5
View File
@@ -340,6 +340,11 @@
}
},
"playlists": {
"share": "Поделиться плейлистом",
"link_copied": "Ссылка на плейлист скопирована",
"shared": "Доступ по ссылке",
"revoke_share": "Отключить доступ по ссылке",
"share_hint": "Любой пользователь этого сервера со ссылкой сможет слушать плейлист. Изменять его можете только вы.",
"name": "Название",
"delete_confirm": "Удалить плейлист «{{name}}»? Треки останутся в библиотеке.",
"manage": "Управлять треками",
+604 -288
View File
@@ -742,9 +742,12 @@ type Playlist struct {
Id openapi_types.UUID `json:"id"`
Name string `json:"name"`
Revision int `json:"revision"`
TrackCount int `json:"track_count"`
Tracks []Track `json:"tracks"`
UpdatedAt time.Time `json:"updated_at"`
// ShareId Действующая ссылка; возвращается только владельцу через /me/playlists
ShareId *openapi_types.UUID `json:"share_id"`
TrackCount int `json:"track_count"`
Tracks []Track `json:"tracks"`
UpdatedAt time.Time `json:"updated_at"`
}
// PlaylistInput defines model for PlaylistInput.
@@ -759,6 +762,11 @@ type PlaylistList struct {
Items []PlaylistSummary `json:"items"`
}
// PlaylistShare defines model for PlaylistShare.
type PlaylistShare struct {
ShareId openapi_types.UUID `json:"share_id"`
}
// PlaylistSummary defines model for PlaylistSummary.
type PlaylistSummary struct {
CreatedAt time.Time `json:"created_at"`
@@ -1901,6 +1909,12 @@ type ServerInterface interface {
// (PUT /api/v1/me/playlists/{id})
UpdatePlaylist(w http.ResponseWriter, r *http.Request, id openapi_types.UUID)
// Отозвать ссылку (только владелец); повторная публикация создаёт новую ссылку
// (DELETE /api/v1/me/playlists/{id}/share)
RevokePlaylistShare(w http.ResponseWriter, r *http.Request, id openapi_types.UUID)
// Создать или вернуть ссылку только для чтения (только владелец)
// (POST /api/v1/me/playlists/{id}/share)
SharePlaylist(w http.ResponseWriter, r *http.Request, id openapi_types.UUID)
// Снять реакцию
// (DELETE /api/v1/me/reactions/tracks/{id})
ClearTrackReaction(w http.ResponseWriter, r *http.Request, id EntityId)
@@ -1925,6 +1939,9 @@ type ServerInterface interface {
// Сведения о сервере для клиентов
// (GET /api/v1/server-info)
GetServerInfo(w http.ResponseWriter, r *http.Request)
// Прочитать плейлист по действующей ссылке (требуется вход)
// (GET /api/v1/shared/playlists/{shareId})
GetSharedPlaylist(w http.ResponseWriter, r *http.Request, shareId openapi_types.UUID)
// Аудио трека (поддерживает Range)
// (GET /api/v1/stream/{id})
StreamTrack(w http.ResponseWriter, r *http.Request, id EntityId, params StreamTrackParams)
@@ -2362,6 +2379,18 @@ func (_ Unimplemented) UpdatePlaylist(w http.ResponseWriter, r *http.Request, id
w.WriteHeader(http.StatusNotImplemented)
}
// Отозвать ссылку (только владелец); повторная публикация создаёт новую ссылку
// (DELETE /api/v1/me/playlists/{id}/share)
func (_ Unimplemented) RevokePlaylistShare(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) {
w.WriteHeader(http.StatusNotImplemented)
}
// Создать или вернуть ссылку только для чтения (только владелец)
// (POST /api/v1/me/playlists/{id}/share)
func (_ Unimplemented) SharePlaylist(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) {
w.WriteHeader(http.StatusNotImplemented)
}
// Снять реакцию
// (DELETE /api/v1/me/reactions/tracks/{id})
func (_ Unimplemented) ClearTrackReaction(w http.ResponseWriter, r *http.Request, id EntityId) {
@@ -2410,6 +2439,12 @@ func (_ Unimplemented) GetServerInfo(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotImplemented)
}
// Прочитать плейлист по действующей ссылке (требуется вход)
// (GET /api/v1/shared/playlists/{shareId})
func (_ Unimplemented) GetSharedPlaylist(w http.ResponseWriter, r *http.Request, shareId openapi_types.UUID) {
w.WriteHeader(http.StatusNotImplemented)
}
// Аудио трека (поддерживает Range)
// (GET /api/v1/stream/{id})
func (_ Unimplemented) StreamTrack(w http.ResponseWriter, r *http.Request, id EntityId, params StreamTrackParams) {
@@ -4388,6 +4423,68 @@ func (siw *ServerInterfaceWrapper) UpdatePlaylist(w http.ResponseWriter, r *http
handler.ServeHTTP(w, r)
}
// RevokePlaylistShare operation middleware
func (siw *ServerInterfaceWrapper) RevokePlaylistShare(w http.ResponseWriter, r *http.Request) {
var err error
// ------------- Path parameter "id" -------------
var id openapi_types.UUID
err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
return
}
ctx := r.Context()
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
r = r.WithContext(ctx)
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.RevokePlaylistShare(w, r, id)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// SharePlaylist operation middleware
func (siw *ServerInterfaceWrapper) SharePlaylist(w http.ResponseWriter, r *http.Request) {
var err error
// ------------- Path parameter "id" -------------
var id openapi_types.UUID
err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
return
}
ctx := r.Context()
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
r = r.WithContext(ctx)
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.SharePlaylist(w, r, id)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// ClearTrackReaction operation middleware
func (siw *ServerInterfaceWrapper) ClearTrackReaction(w http.ResponseWriter, r *http.Request) {
@@ -4639,6 +4736,37 @@ func (siw *ServerInterfaceWrapper) GetServerInfo(w http.ResponseWriter, r *http.
handler.ServeHTTP(w, r)
}
// GetSharedPlaylist operation middleware
func (siw *ServerInterfaceWrapper) GetSharedPlaylist(w http.ResponseWriter, r *http.Request) {
var err error
// ------------- Path parameter "shareId" -------------
var shareId openapi_types.UUID
err = runtime.BindStyledParameterWithOptions("simple", "shareId", chi.URLParam(r, "shareId"), &shareId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "shareId", Err: err})
return
}
ctx := r.Context()
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
r = r.WithContext(ctx)
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetSharedPlaylist(w, r, shareId)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// StreamTrack operation middleware
func (siw *ServerInterfaceWrapper) StreamTrack(w http.ResponseWriter, r *http.Request) {
@@ -5448,6 +5576,12 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
r.Group(func(r chi.Router) {
r.Put(options.BaseURL+"/api/v1/me/playlists/{id}", wrapper.UpdatePlaylist)
})
r.Group(func(r chi.Router) {
r.Delete(options.BaseURL+"/api/v1/me/playlists/{id}/share", wrapper.RevokePlaylistShare)
})
r.Group(func(r chi.Router) {
r.Post(options.BaseURL+"/api/v1/me/playlists/{id}/share", wrapper.SharePlaylist)
})
r.Group(func(r chi.Router) {
r.Delete(options.BaseURL+"/api/v1/me/reactions/tracks/{id}", wrapper.ClearTrackReaction)
})
@@ -5472,6 +5606,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/api/v1/server-info", wrapper.GetServerInfo)
})
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/api/v1/shared/playlists/{shareId}", wrapper.GetSharedPlaylist)
})
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/api/v1/stream/{id}", wrapper.StreamTrack)
})
@@ -7400,6 +7537,63 @@ func (response UpdatePlaylistdefaultApplicationProblemPlusJSONResponse) VisitUpd
return json.NewEncoder(w).Encode(response.Body)
}
type RevokePlaylistShareRequestObject struct {
Id openapi_types.UUID `json:"id"`
}
type RevokePlaylistShareResponseObject interface {
VisitRevokePlaylistShareResponse(w http.ResponseWriter) error
}
type RevokePlaylistShare204Response struct {
}
func (response RevokePlaylistShare204Response) VisitRevokePlaylistShareResponse(w http.ResponseWriter) error {
w.WriteHeader(204)
return nil
}
type RevokePlaylistSharedefaultApplicationProblemPlusJSONResponse struct {
Body Problem
StatusCode int
}
func (response RevokePlaylistSharedefaultApplicationProblemPlusJSONResponse) VisitRevokePlaylistShareResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(response.StatusCode)
return json.NewEncoder(w).Encode(response.Body)
}
type SharePlaylistRequestObject struct {
Id openapi_types.UUID `json:"id"`
}
type SharePlaylistResponseObject interface {
VisitSharePlaylistResponse(w http.ResponseWriter) error
}
type SharePlaylist200JSONResponse PlaylistShare
func (response SharePlaylist200JSONResponse) VisitSharePlaylistResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response)
}
type SharePlaylistdefaultApplicationProblemPlusJSONResponse struct {
Body Problem
StatusCode int
}
func (response SharePlaylistdefaultApplicationProblemPlusJSONResponse) VisitSharePlaylistResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(response.StatusCode)
return json.NewEncoder(w).Encode(response.Body)
}
type ClearTrackReactionRequestObject struct {
Id EntityId `json:"id"`
}
@@ -7627,6 +7821,35 @@ func (response GetServerInfodefaultApplicationProblemPlusJSONResponse) VisitGetS
return json.NewEncoder(w).Encode(response.Body)
}
type GetSharedPlaylistRequestObject struct {
ShareId openapi_types.UUID `json:"shareId"`
}
type GetSharedPlaylistResponseObject interface {
VisitGetSharedPlaylistResponse(w http.ResponseWriter) error
}
type GetSharedPlaylist200JSONResponse Playlist
func (response GetSharedPlaylist200JSONResponse) VisitGetSharedPlaylistResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response)
}
type GetSharedPlaylistdefaultApplicationProblemPlusJSONResponse struct {
Body Problem
StatusCode int
}
func (response GetSharedPlaylistdefaultApplicationProblemPlusJSONResponse) VisitGetSharedPlaylistResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(response.StatusCode)
return json.NewEncoder(w).Encode(response.Body)
}
type StreamTrackRequestObject struct {
Id EntityId `json:"id"`
Params StreamTrackParams
@@ -8323,6 +8546,12 @@ type StrictServerInterface interface {
// (PUT /api/v1/me/playlists/{id})
UpdatePlaylist(ctx context.Context, request UpdatePlaylistRequestObject) (UpdatePlaylistResponseObject, error)
// Отозвать ссылку (только владелец); повторная публикация создаёт новую ссылку
// (DELETE /api/v1/me/playlists/{id}/share)
RevokePlaylistShare(ctx context.Context, request RevokePlaylistShareRequestObject) (RevokePlaylistShareResponseObject, error)
// Создать или вернуть ссылку только для чтения (только владелец)
// (POST /api/v1/me/playlists/{id}/share)
SharePlaylist(ctx context.Context, request SharePlaylistRequestObject) (SharePlaylistResponseObject, error)
// Снять реакцию
// (DELETE /api/v1/me/reactions/tracks/{id})
ClearTrackReaction(ctx context.Context, request ClearTrackReactionRequestObject) (ClearTrackReactionResponseObject, error)
@@ -8347,6 +8576,9 @@ type StrictServerInterface interface {
// Сведения о сервере для клиентов
// (GET /api/v1/server-info)
GetServerInfo(ctx context.Context, request GetServerInfoRequestObject) (GetServerInfoResponseObject, error)
// Прочитать плейлист по действующей ссылке (требуется вход)
// (GET /api/v1/shared/playlists/{shareId})
GetSharedPlaylist(ctx context.Context, request GetSharedPlaylistRequestObject) (GetSharedPlaylistResponseObject, error)
// Аудио трека (поддерживает Range)
// (GET /api/v1/stream/{id})
StreamTrack(ctx context.Context, request StreamTrackRequestObject) (StreamTrackResponseObject, error)
@@ -10207,6 +10439,58 @@ func (sh *strictHandler) UpdatePlaylist(w http.ResponseWriter, r *http.Request,
}
}
// RevokePlaylistShare operation middleware
func (sh *strictHandler) RevokePlaylistShare(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) {
var request RevokePlaylistShareRequestObject
request.Id = id
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
return sh.ssi.RevokePlaylistShare(ctx, request.(RevokePlaylistShareRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "RevokePlaylistShare")
}
response, err := handler(r.Context(), w, r, request)
if err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
} else if validResponse, ok := response.(RevokePlaylistShareResponseObject); ok {
if err := validResponse.VisitRevokePlaylistShareResponse(w); err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
}
} else if response != nil {
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
}
}
// SharePlaylist operation middleware
func (sh *strictHandler) SharePlaylist(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) {
var request SharePlaylistRequestObject
request.Id = id
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
return sh.ssi.SharePlaylist(ctx, request.(SharePlaylistRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "SharePlaylist")
}
response, err := handler(r.Context(), w, r, request)
if err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
} else if validResponse, ok := response.(SharePlaylistResponseObject); ok {
if err := validResponse.VisitSharePlaylistResponse(w); err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
}
} else if response != nil {
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
}
}
// ClearTrackReaction operation middleware
func (sh *strictHandler) ClearTrackReaction(w http.ResponseWriter, r *http.Request, id EntityId) {
var request ClearTrackReactionRequestObject
@@ -10416,6 +10700,32 @@ func (sh *strictHandler) GetServerInfo(w http.ResponseWriter, r *http.Request) {
}
}
// GetSharedPlaylist operation middleware
func (sh *strictHandler) GetSharedPlaylist(w http.ResponseWriter, r *http.Request, shareId openapi_types.UUID) {
var request GetSharedPlaylistRequestObject
request.ShareId = shareId
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
return sh.ssi.GetSharedPlaylist(ctx, request.(GetSharedPlaylistRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "GetSharedPlaylist")
}
response, err := handler(r.Context(), w, r, request)
if err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
} else if validResponse, ok := response.(GetSharedPlaylistResponseObject); ok {
if err := validResponse.VisitGetSharedPlaylistResponse(w); err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
}
} else if response != nil {
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
}
}
// StreamTrack operation middleware
func (sh *strictHandler) StreamTrack(w http.ResponseWriter, r *http.Request, id EntityId, params StreamTrackParams) {
var request StreamTrackRequestObject
@@ -10910,291 +11220,297 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
"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",
"7iBqh3IBAA==",
"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",
"FISFPIV+v3Hv1r3/PwAA//8ZFRgBKHoBAA==",
}
// GetSwagger returns the content of the embedded swagger specification file
@@ -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;