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

+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);
}