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
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:
1 parent
bfcf6e48b2
commit
0a59059738
23 files changed
+1164
-319
No files matched your search
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
|
||||
for (const width of [320, 1280]) {
|
||||
test(`playlists at ${width}px`, async ({ page }, info) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Логин', { exact: true }).fill('alice');
|
||||
@@ -30,10 +31,22 @@ for (const width of [320, 1280]) {
|
||||
await page.getByRole('link', { name: /Музыка для долгой дороги/ }).click();
|
||||
await page.getByRole('button', { name: 'Управлять треками' }).click();
|
||||
await expect(page.getByRole('button', { name: /Убрать .* из плейлиста/ })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click();
|
||||
await expect(page.getByRole('status')).toHaveText('Ссылка на плейлист скопирована');
|
||||
const sharedUrl = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(sharedUrl).toMatch(/^http:\/\/127\.0\.0\.1:4173\/shared\/playlist\/[a-f0-9-]+$/);
|
||||
await expect(page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true })).toBeVisible();
|
||||
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const main = document.querySelector('main')!;
|
||||
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
|
||||
})).toBeLessThanOrEqual(1);
|
||||
await page.screenshot({ path: info.outputPath('playlist.png') });
|
||||
await page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true })).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click();
|
||||
await expect(page.getByRole('status')).toHaveText('Ссылка на плейлист скопирована');
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).not.toBe(sharedUrl);
|
||||
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { App } from './App';
|
||||
import { createAppRouter, safeRedirect } from './app/router';
|
||||
import { fakeApi, makeUser, type FakeUser } from './test/fakeApi';
|
||||
import { albumTracks, fakeApi, makeUser, type FakeUser } from './test/fakeApi';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -506,3 +506,55 @@ describe('recommendation preferences', () => {
|
||||
expect(api.calls.filter((c) => c.path === '/api/v1/me/onboarding').at(-1)?.body).toEqual({ artist_ids: [], genres: [], gender: null, age_group: null });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('playlist sharing', () => {
|
||||
it('copies a stable read-only link and revokes it without exposing editing controls', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
const { user, router, session, api } = await renderApp([alice], '/', { signedInAs: alice });
|
||||
const created = await session.client.POST('/api/v1/me/playlists', { body: { name: 'Общий микс', description: 'Для друзей', track_ids: [albumTracks[0]!.id] } });
|
||||
const playlist = created.data!;
|
||||
await router.navigate({ to: '/playlist/$id', params: { id: playlist.id } });
|
||||
await screen.findByRole('heading', { name: 'Общий микс' });
|
||||
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('Ссылка на плейлист скопирована');
|
||||
const shareId = api.playlists.get(playlist.id)!.share_id!;
|
||||
expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}/shared/playlist/${shareId}`);
|
||||
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Поделиться плейлистом' })).toBeEnabled());
|
||||
expect(api.calls.filter(c => c.method === 'POST' && c.path.endsWith('/share'))).toHaveLength(2);
|
||||
expect(api.playlists.get(playlist.id)?.share_id).toBe(shareId);
|
||||
await router.navigate({ to: '/shared/playlist/$shareId', params: { shareId } });
|
||||
await screen.findByRole('heading', { name: 'Общий микс' });
|
||||
expect(screen.queryByRole('button', { name: 'Изменить плейлист' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Управлять треками' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Удалить плейлист' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Слушать всё' }));
|
||||
await screen.findByRole('contentinfo', { name: 'Плеер' });
|
||||
await router.navigate({ to: '/playlist/$id', params: { id: playlist.id } });
|
||||
await user.click(await screen.findByRole('button', { name: 'Отключить доступ по ссылке' }));
|
||||
await waitFor(() => expect(api.playlists.get(playlist.id)?.share_id).toBeNull());
|
||||
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
|
||||
await waitFor(() => expect(api.playlists.get(playlist.id)?.share_id).toBeTruthy());
|
||||
expect(api.playlists.get(playlist.id)?.share_id).not.toBe(shareId);
|
||||
await router.navigate({ to: '/shared/playlist/$shareId', params: { shareId } });
|
||||
await screen.findByRole('alert');
|
||||
expect(screen.queryByRole('heading', { name: 'Общий микс' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('returns to the shared playlist after login without starting playback', async () => {
|
||||
const bob = makeUser('bob', 'user');
|
||||
const shareId = '00000000-0000-4000-9001-000000000001';
|
||||
const { api, user, router } = await renderApp([bob], `/shared/playlist/${shareId}`);
|
||||
api.playlists.set('00000000-0000-4000-9000-000000000001', { id: '00000000-0000-4000-9000-000000000001', name: 'От друга', description: '', revision: 1, tracks: [albumTracks[0]!], track_count: 1, duration_ms: 200000, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', share_id: shareId });
|
||||
await screen.findByRole('heading', { name: 'Вход' });
|
||||
await user.type(screen.getByLabelText('Логин'), bob.username);
|
||||
await user.type(screen.getByLabelText('Пароль'), bob.password);
|
||||
await user.click(screen.getByRole('button', { name: 'Войти' }));
|
||||
await screen.findByRole('heading', { name: 'От друга' });
|
||||
expect(router.state.location.pathname).toBe(`/shared/playlist/${shareId}`);
|
||||
expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Изменить плейлист' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -145,6 +145,7 @@ const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
|
||||
|
||||
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
|
||||
const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> });
|
||||
const sharedPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/shared/playlist/$shareId', component: () => <PlaylistPage key={sharedPlaylistRoute.useParams().shareId} id={sharedPlaylistRoute.useParams().shareId} shared /> });
|
||||
const playlistsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/playlists', component: PlaylistsPage });
|
||||
const playlistRoute = createRoute({ getParentRoute: () => appRoute, path: '/playlist/$id', component: () => <PlaylistPage id={playlistRoute.useParams().id} /> });
|
||||
|
||||
@@ -280,6 +281,7 @@ const routeTree = rootRoute.addChildren([
|
||||
homeRoute,
|
||||
trackRoute,
|
||||
playlistsRoute,
|
||||
sharedPlaylistRoute,
|
||||
playlistRoute,
|
||||
settingsRoute,
|
||||
searchRoute,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSession } from '../app/session';
|
||||
import { useSession, useUser } from '../app/session';
|
||||
import { useErrorText } from '../shared/lib/errors';
|
||||
import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui';
|
||||
import { playlistAddIcon } from '../shared/ui/icons';
|
||||
@@ -18,10 +18,13 @@ export function usePlaylists(enabled = true) {
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
|
||||
}
|
||||
|
||||
export function usePlaylist(id: string) {
|
||||
export function usePlaylist(id: string, shared = false) {
|
||||
const session = useSession();
|
||||
return useQuery({ queryKey: ['playlists', id],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
|
||||
const user = useUser();
|
||||
return useQuery({ queryKey: shared ? ['shared-playlists', id, user.id] : ['playlists', id], staleTime: 0,
|
||||
queryFn: async () => shared
|
||||
? unwrap(await session.client.GET('/api/v1/shared/playlists/{shareId}', { params: { path: { shareId: id } } }))
|
||||
: unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
|
||||
}
|
||||
|
||||
export function playlistInput(p: Playlist): PlaylistInput & { revision: number } {
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { shareTrack } from './share';
|
||||
import { sharePlaylist, shareTrack } from './share';
|
||||
import * as desktop from '../../desktop';
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
@@ -38,3 +39,34 @@ describe('shareTrack', () => {
|
||||
finally { vi.unstubAllGlobals(); }
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('sharePlaylist', () => {
|
||||
it('copies the shared page URL without exposing streaming links', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
vi.stubGlobal('navigator', { userAgent: 'Windows', clipboard: { writeText } });
|
||||
try {
|
||||
expect(await sharePlaylist('token', 'Mix')).toBe('copied');
|
||||
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/shared/playlist/token`);
|
||||
} finally { vi.unstubAllGlobals(); }
|
||||
});
|
||||
|
||||
it('uses the configured music server when sharing from desktop', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
vi.spyOn(desktop, 'desktopHandle').mockReturnValue({ server: 'https://music.example' } as desktop.DesktopHandle);
|
||||
vi.stubGlobal('navigator', { userAgent: 'Windows', clipboard: { writeText } });
|
||||
try {
|
||||
await sharePlaylist('token', 'Mix');
|
||||
expect(writeText).toHaveBeenCalledWith('https://music.example/shared/playlist/token');
|
||||
} finally { vi.unstubAllGlobals(); }
|
||||
});
|
||||
|
||||
it('opens the native share sheet on mobile browsers', async () => {
|
||||
const share = vi.fn().mockResolvedValue(undefined);
|
||||
vi.stubGlobal('navigator', { userAgent: 'Android', share });
|
||||
try {
|
||||
expect(await sharePlaylist('token', 'Mix')).toBe('shared');
|
||||
expect(share).toHaveBeenCalledWith({ title: 'Mix', url: `${window.location.origin}/shared/playlist/token` });
|
||||
} finally { vi.unstubAllGlobals(); }
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,16 @@
|
||||
import { trackShareUrl } from '@pipu/core';
|
||||
import { playlistShareUrl, trackShareUrl } from '@pipu/core';
|
||||
import { desktopHandle } from '../../desktop';
|
||||
|
||||
/** Desktop always copies; mobile browsers can open their native share sheet. */
|
||||
export async function shareTrack(id: string, title: string): Promise<'copied' | 'shared' | 'cancelled'> {
|
||||
const url = trackShareUrl(window.location.origin, id);
|
||||
return shareUrl(trackShareUrl(desktopHandle()?.server ?? window.location.origin, id), title);
|
||||
}
|
||||
|
||||
export async function sharePlaylist(shareId: string, title: string): Promise<'copied' | 'shared' | 'cancelled'> {
|
||||
return shareUrl(playlistShareUrl(desktopHandle()?.server ?? window.location.origin, shareId), title);
|
||||
}
|
||||
|
||||
async function shareUrl(url: string, title: string): Promise<'copied' | 'shared' | 'cancelled'> {
|
||||
if (/Android|iPhone|iPad|iPod/i.test(navigator.userAgent) && navigator.share) {
|
||||
try {
|
||||
await navigator.share({ title, url });
|
||||
|
||||
@@ -76,6 +76,7 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
let token = 0;
|
||||
const calls: { method: string; path: string; body: unknown }[] = [];
|
||||
const reactions = new Map<string, 'like' | 'dislike'>();
|
||||
const playlistOwners = new Map<string, string>();
|
||||
const playlists = new Map<string, components['schemas']['Playlist']>();
|
||||
|
||||
const me = (u: FakeUser): Me => ({
|
||||
@@ -157,20 +158,37 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
|
||||
if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized');
|
||||
|
||||
if (path === '/api/v1/me/playlists' && req.method === 'GET') return json({ items: [...playlists.values()] });
|
||||
|
||||
const sharedPath = /^\/api\/v1\/shared\/playlists\/([^/]+)$/.exec(path);
|
||||
if (sharedPath && req.method === 'GET') {
|
||||
const item = [...playlists.values()].find(p => p.share_id === sharedPath[1]);
|
||||
return item ? json({ ...item, share_id: null }) : problem(404, 'playlist.not_found');
|
||||
}
|
||||
const sharePath = /^\/api\/v1\/me\/playlists\/([^/]+)\/share$/.exec(path);
|
||||
if (sharePath) {
|
||||
const item = playlists.get(sharePath[1]!);
|
||||
if (!item || playlistOwners.get(item.id) !== user.id) return problem(404, 'playlist.not_found');
|
||||
if (req.method === 'POST') {
|
||||
const share_id = item.share_id ?? `00000000-0000-4000-9001-${String(++seq).padStart(12, '0')}`;
|
||||
playlists.set(item.id, { ...item, share_id }); return json({ share_id });
|
||||
}
|
||||
if (req.method === 'DELETE') { playlists.set(item.id, { ...item, share_id: null }); return new Response(null, { status: 204 }); }
|
||||
}
|
||||
if (path === '/api/v1/me/playlists' && req.method === 'GET') return json({ items: [...playlists.values()].filter(p => !playlistOwners.has(p.id) || playlistOwners.get(p.id) === user.id) });
|
||||
const playlistPath = /^\/api\/v1\/me\/playlists\/([^/]+)$/.exec(path);
|
||||
if (path === '/api/v1/me/playlists' && req.method === 'POST' || playlistPath) {
|
||||
const id = playlistPath?.[1] ?? `00000000-0000-4000-9000-${String(++seq).padStart(12, '0')}`;
|
||||
const old = playlists.get(id);
|
||||
if (playlistPath && !old) return problem(404, 'playlist.not_found');
|
||||
if (playlistPath && (!old || (playlistOwners.has(id) && playlistOwners.get(id) !== user.id))) return problem(404, 'playlist.not_found');
|
||||
if (req.method === 'GET') return json(old);
|
||||
if (req.method === 'DELETE') { playlists.delete(id); return new Response(null, { status: 204 }); }
|
||||
if (old && body?.revision !== old.revision) return problem(409, 'playlist.conflict');
|
||||
const tracks = (body?.track_ids as string[]).map(id => albumTracks.find(t => t.id === id)!);
|
||||
const item = { id, name: body?.name as string, description: body?.description as string, tracks,
|
||||
const item = { ...old, id, name: body?.name as string, description: body?.description as string, tracks,
|
||||
track_count: tracks.length, duration_ms: tracks.reduce((sum, t) => sum + t.duration_ms, 0),
|
||||
revision: (old?.revision ?? 0) + 1, created_at: old?.created_at ?? new Date().toISOString(), updated_at: new Date().toISOString() };
|
||||
playlists.set(id, item);
|
||||
if (!old) playlistOwners.set(id, user.id);
|
||||
return json(item, old ? 200 : 201);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user