Compare commits

...
10 Commits
Author SHA1 Message Date
mrleo1nid 66d3897f66 feat(admin): group navigation into media, uploads and system
ci / ts / ts (packages + web) (push) Failing after 4m33s
release / android (APK в релиз) (push) Successful in 4m45s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m35s
release / release (версия и заметки) (push) Successful in 8s
release / desktop (Linux и Windows в релиз) (push) Successful in 12m14s
release / prune (старые файлы и прогоны) (push) Successful in 12s
2026-10-28 23:47:17 +03:00
mrleo1nid 17bb0e1846 fix(uploads): allow WebAssembly hashing under CSP
ci / ts / ts (packages + web) (push) Failing after 4m29s
ci / desktop / rust (desktop proxy) (push) Successful in 5m54s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 3m23s
2026-10-28 23:34:42 +03:00
mrleo1nidandClaude Sonnet 5.5 17c85493b7 fix(library): always split comma-separated artists even if glued name exists
ci / api / api (push) Successful in 34s
ci / db / db (push) Successful in 42s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 15:26:49 +03:00
mrleo1nidandClaude Sonnet 5.5 75f9c57eca fix(library): split compound album artist and re-split artists on rescan
ci / api / api (push) Successful in 37s
ci / db / db (push) Successful in 55s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 14:29:50 +03:00
mrleo1nidandClaude Sonnet 5.5 8a007be036 feat(web): show spinner on play button while track is loading
ci / ts / ts (packages + web) (push) Successful in 3m15s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 14:23:19 +03:00
mrleo1nidandClaude Sonnet 5.5 f77148df2c fix(web): show notification count as menu badge and mark read on open
ci / ts / ts (packages + web) (push) Successful in 2m52s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 13:56:05 +03:00
mrleo1nidandClaude Sonnet 5.5 139dd226fa feat(genius): fetch artist avatars by name and match names with transliteration suffix
ci / api / api (push) Successful in 27s
ci / db / db (push) Successful in 53s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 13:50:55 +03:00
mrleo1nidandClaude Sonnet 5.5 795bb7de46 fix(library): split comma-separated main artists
ci / module boundaries / boundaries (push) Successful in 8s
ci / api / api (push) Successful in 1m17s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m21s
ci / ts / ts (packages + web) (push) Successful in 3m43s
ci / db / db (push) Canceled after 11m9s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 13:39:32 +03:00
mrleo1nid 11b890c2e7 docs: record architecture and correctness review 2026-10-24 02:16:05 +03:00
mrleo1nid 59983ed1b3 fix(social): complete shared listening request lifecycle 2026-10-24 02:16:00 +03:00
38 changed files with 816 additions and 41 deletions

No files matched your search

+1 -1
View File
@@ -13,7 +13,7 @@
"withGlobalTauri": true, "withGlobalTauri": true,
"windows": [{ "label": "main", "title": "Pipu Music", "width": 1200, "height": 800, "minWidth": 720, "minHeight": 540 }], "windows": [{ "label": "main", "title": "Pipu Music", "width": 1200, "height": 800, "minWidth": 720, "minHeight": 540 }],
"security": { "security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http://127.0.0.1:*; font-src 'self' data:; media-src 'self' blob: http://127.0.0.1:*; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:*; object-src 'none'; base-uri 'self'; frame-src 'none'" "csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http://127.0.0.1:*; font-src 'self' data:; media-src 'self' blob: http://127.0.0.1:*; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:*; object-src 'none'; base-uri 'self'; frame-src 'none'"
} }
}, },
"bundle": { "bundle": {
@@ -38,7 +38,7 @@ export function ListenTogetherButton({ person }: { person: SocialPerson }) {
function TogetherSheet({ person, close }: { person: SocialPerson; close: () => void }) { function TogetherSheet({ person, close }: { person: SocialPerson; close: () => void }) {
const { t } = useTranslation(); const { api, key } = useListening(); const cache = useQueryClient(); const state = usePlayerState(); const errorText = useErrorText(); const { t } = useTranslation(); const { api, key } = useListening(); const cache = useQueryClient(); const state = usePlayerState(); const errorText = useErrorText();
const [kind, setKind] = useState<'join' | 'invite'>(person.activity ? 'join' : 'invite'); const [id] = useState(() => eventId(Date.now())); const [kind, setKind] = useState<'join' | 'invite'>(person.activity ? 'join' : 'invite'); const [id] = useState(() => eventId(Date.now()));
const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } }); const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: () => { close(); void cache.invalidateQueries({ queryKey: key }); } });
return <Modal animationType="slide" onRequestClose={close}><Screen><ScrollView contentContainerStyle={{ padding: 20, gap: 16 }}><Button title={t('form.close')} variant="ghost" onPress={close} /><H1>{t('social.together')}</H1><Muted>{person.display_name}</Muted><Muted>{t('social.stream_choice')}</Muted> return <Modal animationType="slide" onRequestClose={close}><Screen><ScrollView contentContainerStyle={{ padding: 20, gap: 16 }}><Button title={t('form.close')} variant="ghost" onPress={close} /><H1>{t('social.together')}</H1><Muted>{person.display_name}</Muted><Muted>{t('social.stream_choice')}</Muted>
<Button title={t('social.join_stream')} variant={kind === 'join' ? 'primary' : 'ghost'} disabled={!person.activity || action.isPending || action.isSuccess} onPress={() => setKind('join')} /> <Button title={t('social.join_stream')} variant={kind === 'join' ? 'primary' : 'ghost'} disabled={!person.activity || action.isPending || action.isSuccess} onPress={() => setKind('join')} />
<Button title={t('social.invite_stream')} variant={kind === 'invite' ? 'primary' : 'ghost'} disabled={!state.track || state.listening?.locked || action.isPending || action.isSuccess} onPress={() => setKind('invite')} /> <Button title={t('social.invite_stream')} variant={kind === 'invite' ? 'primary' : 'ghost'} disabled={!state.track || state.listening?.locked || action.isPending || action.isSuccess} onPress={() => setKind('invite')} />
+73
View File
@@ -0,0 +1,73 @@
import { expect, test, type Page } from '@playwright/test';
async function login(page: Page, username = 'mobile-admin', target = '/') {
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto(`/login?redirect=${encodeURIComponent(target)}`);
await page.getByLabel('Логин', { exact: true }).fill(username);
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill(`${username}-password`);
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
}
test('admin groups related pages and remembers collapsed sections', async ({ page }, info) => {
await login(page);
const nav = page.getByRole('navigation', { name: 'Навигация' });
await nav.getByRole('button', { name: 'Админка', exact: true }).click();
await expect(nav.getByRole('link', { name: 'Пользователи', exact: true })).toBeVisible();
for (const title of ['Медиатека', 'Загрузки', 'Система']) {
await expect(nav.getByRole('button', { name: title, exact: true })).toHaveAttribute('aria-expanded', 'false');
}
await nav.getByRole('button', { name: 'Система', exact: true }).click();
const system = nav.getByRole('group', { name: 'Система', exact: true });
await expect(system.getByRole('link')).toHaveCount(4);
for (const title of ['Обновления', 'Конфигурация', 'Настройки', 'Журнал']) {
await expect(system.getByRole('link', { name: title, exact: true })).toBeVisible();
}
await nav.screenshot({ path: info.outputPath('admin-groups.png') });
// The fake API resets authentication on a full document load; sign in again.
await login(page);
await expect(nav.getByRole('button', { name: 'Система', exact: true })).toHaveAttribute('aria-expanded', 'true');
await nav.getByRole('button', { name: 'Система', exact: true }).click();
await login(page);
await expect(nav.getByRole('button', { name: 'Система', exact: true })).toHaveAttribute('aria-expanded', 'false');
await login(page, 'mobile-admin', '/admin/audit');
await expect(system.getByRole('link', { name: 'Журнал', exact: true })).toBeVisible();
await expect(system.getByRole('link', { name: 'Журнал', exact: true })).toHaveAttribute('data-status', 'active');
await nav.getByRole('button', { name: 'Медиатека', exact: true }).click();
await expect(nav.getByRole('group', { name: 'Медиатека', exact: true }).getByRole('link')).toHaveCount(2);
await nav.getByRole('button', { name: 'Загрузки', exact: true }).click();
await expect(nav.getByRole('group', { name: 'Загрузки', exact: true }).getByRole('link')).toHaveCount(3);
});
test('moderator sees only permitted groups and pages', async ({ page }) => {
await login(page, 'moderator', '/admin/library');
const nav = page.getByRole('navigation', { name: 'Навигация' });
await expect(nav.getByRole('group', { name: 'Медиатека', exact: true }).getByRole('link', { name: 'Библиотека' })).toBeVisible();
await expect(nav.getByRole('button', { name: 'Система', exact: true })).toHaveCount(0);
await expect(nav.getByRole('link', { name: 'Хранилища' })).toHaveCount(0);
await nav.getByRole('button', { name: 'Загрузки', exact: true }).click();
await expect(nav.getByRole('group', { name: 'Загрузки', exact: true }).getByRole('link', { name: 'Очередь', exact: true })).toBeVisible();
});
test('mobile menu exposes grouped pages and closes after navigation', async ({ page }, info) => {
await page.setViewportSize({ width: 390, height: 844 });
await login(page);
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Навигация' });
for (const title of ['Медиатека', 'Загрузки', 'Система']) {
await expect(dialog.getByRole('heading', { name: title, exact: true })).toBeVisible();
}
await dialog.screenshot({ path: info.outputPath('admin-groups-mobile.png') });
await dialog.getByRole('group', { name: 'Система', exact: true }).getByRole('link', { name: 'Обновления', exact: true }).click();
await expect(dialog).not.toBeVisible();
await expect(page).toHaveURL(/\/admin\/updates$/);
});
test('tablet icon sidebar keeps all admin destinations accessible', async ({ page }) => {
await page.setViewportSize({ width: 900, height: 900 });
await login(page);
const nav = page.getByRole('navigation', { name: 'Навигация' });
for (const title of ['Библиотека', 'Хранилища', 'Предложить треки', 'Очередь', 'Журнал']) {
await expect(nav.getByRole('link', { name: title, exact: true })).toBeVisible();
}
});
+49
View File
@@ -0,0 +1,49 @@
import { expect, test } from '@playwright/test';
for (const scenario of ['pending', 'automatic', 'restricted', 'incoming']) {
test(`listening request lifecycle: ${scenario}`, async ({ page }, info) => {
await page.addInitScript(value => {
localStorage.setItem('pipu.locale', 'ru');
localStorage.setItem('pipu.e2e.listening', value);
}, scenario);
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('alice');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await page.getByRole('link', { name: 'Друзья и общение', exact: true }).click();
const banner = page.getByRole('region', { name: 'Слушать вместе', exact: true });
await expect(banner).toHaveCount(0);
if (scenario === 'incoming') {
await expect(page.getByRole('button', { name: 'Принять', exact: true })).toBeVisible();
await page.screenshot({ path: info.outputPath('pending-invite.png'), fullPage: true });
await page.getByRole('button', { name: 'Принять', exact: true }).click();
} else {
await page.getByRole('button', { name: 'Слушать вместе', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Слушать вместе', exact: true });
await expect(dialog.getByRole('radio', { name: 'Подключиться к другу', exact: true })).toBeChecked();
await dialog.getByRole('button', { name: 'Отправить', exact: true }).click();
if (scenario === 'restricted') {
await expect(dialog.getByRole('alert')).toContainText('Совместное прослушивание недоступно');
await expect(dialog.getByRole('alert')).not.toContainText('Чат доступен только друзьям');
await page.screenshot({ path: info.outputPath('request-restricted.png'), fullPage: true });
await dialog.getByRole('button', { name: 'Закрыть', exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(banner).toHaveCount(0);
return;
}
await expect(dialog).toHaveCount(0);
if (scenario === 'pending') {
await expect(page.getByRole('button', { name: 'Отмена', exact: true })).toBeVisible();
await expect(banner).toHaveCount(0);
await page.getByRole('button', { name: 'Отмена', exact: true }).click();
await expect(page.getByRole('button', { name: 'Отмена', exact: true })).toHaveCount(0);
await expect(banner).toHaveCount(0);
return;
}
}
await expect(banner).toContainText('Слушаете вместе с moderator');
await expect(page.getByRole('contentinfo', { name: 'Плеер', exact: true }).getByRole('button', { name: 'Следующий', exact: true })).toBeDisabled();
await banner.getByRole('button', { name: 'Выйти из прослушивания', exact: true }).click();
await expect(banner).toHaveCount(0);
});
}
+48
View File
@@ -0,0 +1,48 @@
import { readFileSync } from 'node:fs';
import { expect, test } from '@playwright/test';
const nginxHeaders = readFileSync(new URL('../../../deploy/nginx/security-headers.conf', import.meta.url), 'utf8');
const desktopConfig = JSON.parse(readFileSync(new URL('../../desktop/src-tauri/tauri.conf.json', import.meta.url), 'utf8'));
const policies: Record<string, string> = {
web: nginxHeaders.match(/add_header Content-Security-Policy "([^"]+)" always;/)![1],
desktop: desktopConfig.app.security.csp,
};
for (const [platform, policy] of Object.entries(policies)) {
test(`upload hash worker runs under ${platform} CSP`, async ({ page }) => {
// Apply the deployed policy to both the document and worker responses.
await page.route('**/*', async (route) => {
if (new URL(route.request().url()).pathname === '/csp-upload-test') {
await route.fulfill({ contentType: 'text/html', body: '<!doctype html><title>Upload CSP</title>', headers: { 'Content-Security-Policy': policy } });
return;
}
if (new URL(route.request().url()).pathname === '/csp-eval-test.js') {
await route.fulfill({ contentType: 'application/javascript', body: `
try { new Function('return 1')(); document.documentElement.dataset.evalBlocked = 'false'; }
catch { document.documentElement.dataset.evalBlocked = 'true'; }
` });
return;
}
const response = await route.fetch();
await route.fulfill({ response, headers: { ...response.headers(), 'Content-Security-Policy': policy } });
});
await page.goto('/csp-upload-test');
const result = await page.evaluate(() => new Promise((resolve, reject) => {
const worker = new Worker('/src/features/uploads/hash.worker.ts', { type: 'module' });
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.onmessage = ({ data }) => {
if (data.type === 'progress') return;
worker.terminate();
resolve(data);
};
worker.postMessage({ id: 'csp-test', file: new File(['abc'], 'track.flac') });
}));
expect(result).toEqual({
id: 'csp-test', type: 'done',
sha256: 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad',
});
// A page script observes CSP; Playwright's evaluate bypasses the eval restriction.
await page.addScriptTag({ url: '/csp-eval-test.js' });
await expect(page.locator('html')).toHaveAttribute('data-eval-blocked', 'true');
});
}
+67
View File
@@ -15,6 +15,73 @@ import { albumTracks, fakeApi, makeUser, type FakeUser } from './test/fakeApi';
afterEach(cleanup); afterEach(cleanup);
describe('social', () => { describe('social', () => {
it.each([false, true])('closes a join dialog and follows only after admission (automatic=%s)', async (automatic) => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient, session } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
api.presence.set(bob.id, Date.now());
api.activityVisibility.set(bob.id, 'friends');
api.activity.set(bob.id, { device_id: crypto.randomUUID(), track_id: albumTracks[0]!.id, track_title: albumTracks[0]!.title, artist: 'Кино', context: 'other', position_ms: 1000 });
api.listeningPreferences.set(bob.id, { requests: 'friends', auto_accept: automatic ? 'friends' : 'nobody', autoplay: true });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Слушать вместе' }));
const dialog = await screen.findByRole('dialog', { name: 'Слушать вместе' });
await user.click(within(dialog).getByRole('button', { name: 'Отправить' }));
await waitFor(() => expect(dialog).not.toBeInTheDocument());
if (!automatic) {
expect(api.listeningMembers.has(alice.id)).toBe(false);
expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument();
expect(api.listeningRequests.size).toBe(1);
const request = [...api.listeningRequests.values()][0]!;
const room = [...api.listeningRooms.values()][0]!;
api.listeningMembers.set(alice.id, room.id);
api.listeningRequests.delete(request.id);
refreshListening(session);
}
await screen.findByText('Слушаете вместе с bob');
await user.click(screen.getByRole('button', { name: 'Выйти из прослушивания' }));
await waitFor(() => expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument());
});
it('closes a pending invitation without announcing a stream and allows cancellation', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient, router } = await renderApp([alice, bob], `/track/${albumTracks[0]!.id}`, { signedInAs: alice });
await user.click(await screen.findByRole('button', { name: 'Слушать' }));
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
api.presence.set(bob.id, Date.now());
api.listeningPreferences.set(bob.id, { requests: 'friends', auto_accept: 'nobody', autoplay: true });
await router.navigate({ to: '/friends' });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Слушать вместе' }));
const dialog = await screen.findByRole('dialog', { name: 'Слушать вместе' });
await user.click(within(dialog).getByRole('button', { name: 'Отправить' }));
await waitFor(() => expect(dialog).not.toBeInTheDocument());
expect(api.listeningMembers.has(bob.id)).toBe(false);
expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument();
await user.click(await screen.findByRole('button', { name: 'Отмена' }));
await waitFor(() => expect(api.listeningRequests.size).toBe(0));
expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument();
});
it('explains listening restrictions instead of reporting a chat error and can close after rejection', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
api.presence.set(bob.id, Date.now());
api.activityVisibility.set(bob.id, 'friends');
api.activity.set(bob.id, { device_id: crypto.randomUUID(), track_id: albumTracks[0]!.id, track_title: albumTracks[0]!.title, artist: 'Кино', context: 'other', position_ms: 0 });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Слушать вместе' }));
const dialog = await screen.findByRole('dialog', { name: 'Слушать вместе' });
await user.click(within(dialog).getByRole('button', { name: 'Отправить' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('Совместное прослушивание недоступно');
expect(within(dialog).getByRole('alert')).not.toHaveTextContent('Чат доступен только друзьям');
expect(api.listeningRequests.size).toBe(0);
expect(api.listeningRooms.size).toBe(0);
await user.click(within(dialog).getByRole('button', { name: 'Закрыть' }));
expect(dialog).not.toBeInTheDocument();
});
it('enables listening together only while a friend is online, independently of music privacy', async () => { it('enables listening together only while a friend is online, independently of music privacy', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user'); const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice }); const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
+1 -1
View File
@@ -42,7 +42,7 @@ function ListenTogetherDialog({ person, close }: { person: SocialPerson; close:
const { api, key } = useListening(); const state = usePlayerState(); const { api, key } = useListening(); const state = usePlayerState();
const [kind, setKind] = useState<'join' | 'invite'>(person.activity ? 'join' : 'invite'); const [kind, setKind] = useState<'join' | 'invite'>(person.activity ? 'join' : 'invite');
const [id] = useState(uuidv7); const [id] = useState(uuidv7);
const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } }); const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: () => { close(); void cache.invalidateQueries({ queryKey: key }); } });
return <Dialog title={t('social.together')} onClose={close}><strong>{person.display_name}</strong><fieldset className={s.privacy} disabled={action.isPending || action.isSuccess}><legend>{t('social.stream_choice')}</legend> return <Dialog title={t('social.together')} onClose={close}><strong>{person.display_name}</strong><fieldset className={s.privacy} disabled={action.isPending || action.isSuccess}><legend>{t('social.stream_choice')}</legend>
<label className={s.choice}><input type="radio" checked={kind === 'join'} disabled={!person.activity} onChange={() => setKind('join')} />{t('social.join_stream')}</label> <label className={s.choice}><input type="radio" checked={kind === 'join'} disabled={!person.activity} onChange={() => setKind('join')} />{t('social.join_stream')}</label>
<label className={s.choice}><input type="radio" checked={kind === 'invite'} disabled={!state.track || state.listening?.locked} onChange={() => setKind('invite')} />{t('social.invite_stream')}</label> <label className={s.choice}><input type="radio" checked={kind === 'invite'} disabled={!state.track || state.listening?.locked} onChange={() => setKind('invite')} />{t('social.invite_stream')}</label>
@@ -29,7 +29,7 @@ export function NotificationsDialog({ onClose, onOpen }: { onClose: () => void;
{shown.map(event => { {shown.map(event => {
const date = new Date(event.created_at); const date = new Date(event.created_at);
return <article key={event.id} className={`${s.row} ${!event.read ? s.unread : ''}`}> return <article key={event.id} className={`${s.row} ${!event.read ? s.unread : ''}`}>
<button type="button" className={s.open} aria-label={t('social.open_notification', { name: event.actor_name })} onClick={() => onOpen(event.actor_id)}> <button type="button" className={s.open} aria-label={t('social.open_notification', { name: event.actor_name })} onClick={() => { if (!event.read) read.mutate(event.id); onOpen(event.actor_id); }}>
<span className={s.avatar} aria-hidden>{event.actor_name.slice(0, 1).toUpperCase()}</span> <span className={s.avatar} aria-hidden>{event.actor_name.slice(0, 1).toUpperCase()}</span>
<span className={s.content}><strong>{event.actor_name}</strong><span>{t(`social.kind_${event.kind}`)}</span> <span className={s.content}><strong>{event.actor_name}</strong><span>{t(`social.kind_${event.kind}`)}</span>
<time dateTime={event.created_at} title={date.toLocaleString(i18n.language)}>{date.toLocaleString(i18n.language, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</time> <time dateTime={event.created_at} title={date.toLocaleString(i18n.language)}>{date.toLocaleString(i18n.language, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</time>
+1 -1
View File
@@ -86,7 +86,7 @@ export function useSocialProfileMenu() {
return { return {
items: [ items: [
{ label: t(currentVisibility === 'nobody' ? 'social.status_hidden' : 'social.privacy_settings'), icon: currentVisibility === 'nobody' ? visibilityOffIcon : shieldIcon, onSelect: () => setDialog('privacy') }, { label: t(currentVisibility === 'nobody' ? 'social.status_hidden' : 'social.privacy_settings'), icon: currentVisibility === 'nobody' ? visibilityOffIcon : shieldIcon, onSelect: () => setDialog('privacy') },
{ label: `${t('social.notifications')}${inbox.data?.unread ? ` ${inbox.data.unread}` : ''}`, icon: inboxIcon, onSelect: () => setDialog('notifications') }, { label: t('social.notifications'), badge: inbox.data?.unread, icon: inboxIcon, onSelect: () => setDialog('notifications') },
], ],
dialogs: <> dialogs: <>
{dialog === 'privacy' && <Dialog title={t('social.privacy_settings')} onClose={closeDialog} className={s.preferencesDialog}> {dialog === 'privacy' && <Dialog title={t('social.privacy_settings')} onClose={closeDialog} className={s.preferencesDialog}>
+1 -1
View File
@@ -40,7 +40,7 @@ function fixture() {
expect(overlap.volume).toBeGreaterThan(0); expect(overlap.volume).toBeGreaterThan(0);
}; };
const follow = async () => { const follow = async () => {
snapshot = { ...snapshot, room: { id: 'room', owner_id: 'host', owner_name: 'Host', device_id: 'host-device', track_id: remote.id, position_ms: 1000, playing: true, updated_at: snapshot.server_time, participants: [] } }; snapshot = { ...snapshot, room: { id: 'room', owner_id: 'host', owner_name: 'Host', device_id: 'host-device', track_id: remote.id, position_ms: 1000, playing: true, updated_at: snapshot.server_time, participants: [{ id: 'host', name: 'Host' }, { id: 'guest', name: 'Guest' }] } };
player.start(); player.start();
await vi.waitFor(() => expect(player.getState()).toMatchObject({ track: { id: 'remote' }, playing: true, listening: { locked: true } })); await vi.waitFor(() => expect(player.getState()).toMatchObject({ track: { id: 'remote' }, playing: true, listening: { locked: true } }));
}; };
+15
View File
@@ -420,6 +420,21 @@
background: var(--hover); background: var(--hover);
} }
.menuBadge {
margin-left: auto;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 10px;
background: var(--accent-fill);
color: var(--on-accent, #fff);
font-size: 11px;
font-weight: 600;
line-height: 20px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.menuDanger { .menuDanger {
color: var(--danger); color: var(--danger);
} }
+2 -1
View File
@@ -204,7 +204,7 @@ export function Dialog({
); );
} }
export type MenuItem = { label: string; icon?: string; danger?: boolean; onSelect: () => void }; export type MenuItem = { label: string; icon?: string; danger?: boolean; badge?: number; onSelect: () => void };
/** Выпадающее меню: открывается кнопкой-триггером, закрывается кликом снаружи и Esc. */ /** Выпадающее меню: открывается кнопкой-триггером, закрывается кликом снаружи и Esc. */
export function Menu({ export function Menu({
@@ -268,6 +268,7 @@ export function Menu({
> >
{it.icon && <Icon src={it.icon} size={18} />} {it.icon && <Icon src={it.icon} size={18} />}
{it.label} {it.label}
{it.badge ? <span className={s.menuBadge}>{it.badge > 99 ? '99+' : it.badge}</span> : null}
</button> </button>
))} ))}
</div> </div>
+16 -1
View File
@@ -1,4 +1,4 @@
import { fakeApi, makeUser } from './fakeApi'; import { albumTracks, fakeApi, makeUser } from './fakeApi';
import { mobileFixtures } from './mobileFixtures'; import { mobileFixtures } from './mobileFixtures';
/** Подменяет fetch на фейковый API. Включается только при сборке с VITE_E2E=1. */ /** Подменяет fetch на фейковый API. Включается только при сборке с VITE_E2E=1. */
@@ -14,6 +14,21 @@ export function installE2EFetch() {
const alice = [...api.users.values()].find(u => u.username === 'alice')!; const alice = [...api.users.values()].find(u => u.username === 'alice')!;
const friend = [...api.users.values()].find(u => u.username === 'moderator')!; const friend = [...api.users.values()].find(u => u.username === 'moderator')!;
api.friendships.set([alice.id, friend.id].sort().join(':'), { sender: friend.id, accepted: true }); api.friendships.set([alice.id, friend.id].sort().join(':'), { sender: friend.id, accepted: true });
const listeningScenario = localStorage.getItem('pipu.e2e.listening');
if (listeningScenario) {
const device = crypto.randomUUID();
api.presence.set(friend.id, Date.now());
api.activityVisibility.set(friend.id, 'friends');
api.activity.set(friend.id, { device_id: device, track_id: albumTracks[0]!.id, track_title: albumTracks[0]!.title, artist: 'Кино', context: 'other', position_ms: 0 });
api.listeningPreferences.set(friend.id, { requests: listeningScenario === 'restricted' ? 'nobody' : 'friends', auto_accept: listeningScenario === 'automatic' ? 'friends' : 'nobody', autoplay: true });
if (listeningScenario === 'incoming') {
const roomId = crypto.randomUUID();
const requestId = crypto.randomUUID();
api.listeningRooms.set(roomId, { id: roomId, owner_id: friend.id, owner_name: friend.username, device_id: device, track_id: albumTracks[0]!.id, position_ms: 0, playing: false, updated_at: new Date().toISOString(), participants: [] });
api.listeningMembers.set(friend.id, roomId);
api.listeningRequests.set(requestId, { id: requestId, owner_id: friend.id, guest_id: alice.id, sender_id: friend.id, sender_name: friend.username, recipient_id: alice.id, kind: 'invite', expires_at: new Date(Date.now() + 120_000).toISOString() });
}
}
if (localStorage.getItem('pipu.e2e.notifications') === '1') { if (localStorage.getItem('pipu.e2e.notifications') === '1') {
const kinds = ['message', 'friend_request', 'friend_accepted', 'music'] as const; const kinds = ['message', 'friend_request', 'friend_accepted', 'music'] as const;
for (let index = 0; index < 28; index++) api.notifications.push({ for (let index = 0; index < 28; index++) api.notifications.push({
+6 -1
View File
@@ -260,8 +260,13 @@ export function fakeApi(initial: FakeUser[]) {
if(op==='join'||op==='invite'){ if(op==='join'||op==='invite'){
const host=op==='join'?target:user.id; const guest=op==='join'?user.id:target; const prefs=listeningPreferences.get(target)??defaultListening; const host=op==='join'?target:user.id; const guest=op==='join'?user.id:target; const prefs=listeningPreferences.get(target)??defaultListening;
const friend=!!friendships.get(pair(user.id,target))?.accepted; const friend=!!friendships.get(pair(user.id,target))?.accepted;
if(isBlocked(user.id,target)||!(prefs.requests==='everyone'||prefs.requests==='friends'&&friend))return problem(403,'social.unavailable'); if(isBlocked(user.id,target)||!(prefs.requests==='everyone'||prefs.requests==='friends'&&friend))return problem(403,'social.listening_unavailable');
if ((presence.get(target) ?? 0) <= Date.now() - 45_000) return problem(409, 'social.person_offline'); if ((presence.get(target) ?? 0) <= Date.now() - 45_000) return problem(409, 'social.person_offline');
const guestRoom = listeningRooms.get(listeningMembers.get(guest) ?? '');
const hostRoom = listeningRooms.get(listeningMembers.get(host) ?? '');
if (guestRoom && guestRoom.owner_id !== host || hostRoom && hostRoom.owner_id !== host) return problem(409, 'social.listening_busy');
if (guestRoom?.owner_id === host || listeningRequests.has(String(body.id))) return new Response(null, { status: 204 });
if ([...listeningRequests.values()].some(r => r.owner_id === host && r.guest_id === guest)) return problem(409, 'social.listening_pending');
let room=[...listeningRooms.values()].find(r=>r.owner_id===host); let room=[...listeningRooms.values()].find(r=>r.owner_id===host);
const source=activity.get(host);if(!room){room={id:crypto.randomUUID(),owner_id:host,owner_name:users.get(host)?.username??host,device_id:String(op==='join'?source?.device_id:body.device_id),track_id:String(op==='join'?source?.track_id:body.track_id),position_ms:Number(op==='join'?source?.position_ms:body.position_ms??0),playing:true,updated_at:new Date().toISOString(),participants:[]};listeningRooms.set(room.id,room);listeningMembers.set(host,room.id);} const source=activity.get(host);if(!room){room={id:crypto.randomUUID(),owner_id:host,owner_name:users.get(host)?.username??host,device_id:String(op==='join'?source?.device_id:body.device_id),track_id:String(op==='join'?source?.track_id:body.track_id),position_ms:Number(op==='join'?source?.position_ms:body.position_ms??0),playing:true,updated_at:new Date().toISOString(),participants:[]};listeningRooms.set(room.id,room);listeningMembers.set(host,room.id);}
if(prefs.auto_accept==='everyone'||prefs.auto_accept==='friends'&&friend)listeningMembers.set(guest,room.id); if(prefs.auto_accept==='everyone'||prefs.auto_accept==='friends'&&friend)listeningMembers.set(guest,room.id);
+21
View File
@@ -170,6 +170,22 @@
display: none; display: none;
} }
.navSubsection {
min-height: 40px;
margin: 2px 0;
font-size: 13px;
font-weight: 600;
letter-spacing: normal;
text-transform: none;
color: var(--text2);
}
.navSubgroup > .navItem {
margin-left: 12px;
padding-left: 12px;
border-left: 1px solid var(--line);
}
.navItem { .navItem {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -295,6 +311,9 @@
.navGroupFolded { .navGroupFolded {
display: contents; display: contents;
} }
.navSubsection { min-height: 0; }
.navSubgroup > .navItem { margin-left: 0; padding-left: 0; border-left: 0; }
} }
.mobileBottom { display: none; } .mobileBottom { display: none; }
@@ -358,6 +377,8 @@
border: 0; pointer-events: none; border: 0; pointer-events: none;
} }
.mobileMenu .navSectionTitle { display: block; } .mobileMenu .navSectionTitle { display: block; }
.mobileMenu .navSubsection { color: var(--text2); font-size: 13px; }
.mobileMenu .navSubgroup > .navItem { margin-left: 12px; border-left: 1px solid var(--line); }
.mobileMenu .navChevron { display: none; } .mobileMenu .navChevron { display: none; }
.content, .island .content { padding: 20px 16px 24px; } .content, .island .content { padding: 20px 16px 24px; }
} }
+27 -7
View File
@@ -2,7 +2,7 @@ import { queueIcon } from '../shared/ui/icons';
import { unwrap } from '@pipu/core'; import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Link, Outlet, useNavigate, useRouterState } from '@tanstack/react-router'; import { Link, Outlet, useNavigate, useRouterState } from '@tanstack/react-router';
import { Suspense, useCallback, useState, type ReactNode } from 'react'; import { Suspense, useCallback, useEffect, useId, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuthState, useSession, useUser } from '../app/session'; import { useAuthState, useSession, useUser } from '../app/session';
@@ -110,6 +110,8 @@ function NavSection({
children, children,
defaultFolded = false, defaultFolded = false,
mobile = false, mobile = false,
nested = false,
paths,
}: { }: {
id: string; id: string;
title: string; title: string;
@@ -117,12 +119,21 @@ function NavSection({
children: ReactNode; children: ReactNode;
defaultFolded?: boolean; defaultFolded?: boolean;
mobile?: boolean; mobile?: boolean;
nested?: boolean;
paths?: readonly string[];
}) { }) {
const groupId = useId();
const pathname = useRouterState({ select: (state) => state.location.pathname });
const active = paths?.some((path) => pathname === path || pathname.startsWith(`${path}/`)) ?? false;
const [folded, setFolded] = useState(() => { const [folded, setFolded] = useState(() => {
if (active) return false;
if (readIds(FOLDED_KEY).includes(id)) return true; if (readIds(FOLDED_KEY).includes(id)) return true;
if (readIds(TOUCHED_KEY).includes(id)) return false; if (readIds(TOUCHED_KEY).includes(id)) return false;
return defaultFolded; return defaultFolded;
}); });
useEffect(() => {
if (active) setFolded(false);
}, [active, pathname]);
const toggle = () => { const toggle = () => {
const next = !folded; const next = !folded;
setFolded(next); setFolded(next);
@@ -138,15 +149,15 @@ function NavSection({
return ( return (
<> <>
{mobile ? ( {mobile ? (
<h3 className={s.navSection}><span className={s.navSectionTitle}>{title}</span></h3> <h3 className={cx(s.navSection, nested && s.navSubsection)}><span className={s.navSectionTitle}>{title}</span></h3>
) : ( ) : (
<button type="button" className={s.navSection} aria-expanded={!folded} onClick={toggle}> <button type="button" className={cx(s.navSection, nested && s.navSubsection)} aria-expanded={!folded} aria-controls={groupId} onClick={toggle}>
<span className={s.navSectionTitle}>{title}</span> <span className={s.navSectionTitle}>{title}</span>
{folded && badge ? <span className={s.navBadge}>{badge > 99 ? '99+' : badge}</span> : null} {folded && badge ? <span className={s.navBadge}>{badge > 99 ? '99+' : badge}</span> : null}
<Icon src={chevronRightIcon} size={16} className={cx(s.navChevron, !folded && s.navChevronOpen)} /> <Icon src={chevronRightIcon} size={16} className={cx(s.navChevron, !folded && s.navChevronOpen)} />
</button> </button>
)} )}
<div className={cx(s.navGroup, !mobile && folded && s.navGroupFolded)}>{children}</div> <div id={groupId} role="group" aria-label={title} className={cx(s.navGroup, nested && s.navSubgroup, !mobile && folded && s.navGroupFolded)}>{children}</div>
</> </>
); );
} }
@@ -178,6 +189,7 @@ function Shell() {
const reviews = user.reviews_uploads; const reviews = user.reviews_uploads;
const pending = usePendingUploads(reviews); const pending = usePendingUploads(reviews);
const unseen = useUnseenUploads(staff); const unseen = useUnseenUploads(staff);
const uploadBadge = (reviews ? pending ?? 0 : 0) + (unseen ?? 0);
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const closeMobile = useCallback(() => setMobileOpen(false), []); const closeMobile = useCallback(() => setMobileOpen(false), []);
const navigation = (mobile = false) => ( const navigation = (mobile = false) => (
@@ -208,12 +220,15 @@ function Shell() {
)} )}
{!staff && user.manages_updates && <NavLink to="/admin/updates" label={t('nav.server_updates')} icon={downloadIcon} activeIcon={downloadIcon} />} {!staff && user.manages_updates && <NavLink to="/admin/updates" label={t('nav.server_updates')} icon={downloadIcon} activeIcon={downloadIcon} />}
{staff && ( {staff && (
<NavSection mobile={mobile} id="admin" title={t('nav.admin')} badge={reviews ? pending : undefined} defaultFolded> <NavSection mobile={mobile} id="admin" title={t('nav.admin')} badge={uploadBadge} paths={['/admin', '/uploads']} defaultFolded>
<NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} /> <NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
<NavSection mobile={mobile} nested id="admin-media" title={t('nav.admin_media')} paths={['/admin/library', '/admin/sources']} defaultFolded>
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
{user.role === 'admin' && ( {user.role === 'admin' && (
<NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} /> <NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} />
)} )}
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} /> </NavSection>
<NavSection mobile={mobile} nested id="admin-uploads" title={t('nav.admin_uploads')} badge={uploadBadge} paths={['/uploads', '/admin/uploads']} defaultFolded>
<NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} /> <NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} />
<NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} badge={unseen} /> <NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} badge={unseen} />
{reviews && ( {reviews && (
@@ -225,7 +240,10 @@ function Shell() {
badge={pending} badge={pending}
/> />
)} )}
{(user.role === 'admin' || user.manages_updates) && <NavLink to="/admin/updates" label={t('nav.server_updates')} icon={downloadIcon} activeIcon={downloadIcon} />} </NavSection>
{(user.role === 'admin' || user.manages_updates) && (
<NavSection mobile={mobile} nested id="admin-system" title={t('nav.admin_system')} paths={['/admin/updates', '/admin/config', '/admin/settings', '/admin/audit']} defaultFolded>
<NavLink to="/admin/updates" label={t('nav.server_updates')} icon={downloadIcon} activeIcon={downloadIcon} />
{user.role === 'admin' && ( {user.role === 'admin' && (
<> <>
<NavLink to="/admin/config" label={t('nav.server_config')} icon={tuneIcon} activeIcon={tuneFillIcon} /> <NavLink to="/admin/config" label={t('nav.server_config')} icon={tuneIcon} activeIcon={tuneFillIcon} />
@@ -235,6 +253,8 @@ function Shell() {
)} )}
</NavSection> </NavSection>
)} )}
</NavSection>
)}
</> </>
); );
+22 -1
View File
@@ -259,7 +259,28 @@ a.source:hover {
} }
.play[aria-busy='true'] { .play[aria-busy='true'] {
opacity: 0.7; opacity: 0.85;
}
.playSpinner {
width: 22px;
height: 22px;
border: 3px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: playSpin 0.7s linear infinite;
}
@keyframes playSpin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.playSpinner {
animation-duration: 2s;
}
} }
.progress { .progress {
+3 -3
View File
@@ -143,7 +143,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
aria-busy={st.loading || undefined} aria-busy={st.loading || undefined}
disabled={locked} onClick={() => player.toggle()} disabled={locked} onClick={() => player.toggle()}
> >
<Icon src={st.playing ? pauseIcon : playIcon} size={28} /> {st.loading ? <span className={s.playSpinner} aria-hidden /> : <Icon src={st.playing ? pauseIcon : playIcon} size={28} />}
</button> </button>
<button type="button" className={s.icon} aria-label={t('player.next')} disabled={locked} onClick={() => player.next()}> <button type="button" className={s.icon} aria-label={t('player.next')} disabled={locked} onClick={() => player.next()}>
<Icon src={nextIcon} size={26} /> <Icon src={nextIcon} size={26} />
@@ -335,8 +335,8 @@ function NowPlayingSheet({
<button type="button" className={s.icon} aria-label={t('player.prev')} disabled={locked} onClick={() => player.prev()}> <button type="button" className={s.icon} aria-label={t('player.prev')} disabled={locked} onClick={() => player.prev()}>
<Icon src={prevIcon} size={28} /> <Icon src={prevIcon} size={28} />
</button> </button>
<button type="button" className={s.play} aria-label={t(st.playing ? 'player.pause' : 'player.play')} disabled={locked} onClick={() => player.toggle()}> <button type="button" className={s.play} aria-label={t(st.playing ? 'player.pause' : 'player.play')} aria-busy={st.loading || undefined} disabled={locked} onClick={() => player.toggle()}>
<Icon src={st.playing ? pauseIcon : playIcon} size={32} /> {st.loading ? <span className={s.playSpinner} aria-hidden /> : <Icon src={st.playing ? pauseIcon : playIcon} size={32} />}
</button> </button>
<button type="button" className={s.icon} aria-label={t('player.next')} disabled={locked} onClick={() => player.next()}> <button type="button" className={s.icon} aria-label={t('player.next')} disabled={locked} onClick={() => player.next()}>
<Icon src={nextIcon} size={28} /> <Icon src={nextIcon} size={28} />
+2 -1
View File
@@ -3,4 +3,5 @@
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "DENY" always; add_header X-Frame-Options "DENY" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; # hash-wasm считает SHA-256 загрузок в Web Worker; разрешаем WASM без JavaScript eval.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
+9
View File
@@ -14,6 +14,15 @@
## 2. Пользователи и роли ## 2. Пользователи и роли
В меню «Админка» пользователи доступны отдельным пунктом, остальные страницы собраны в подразделы:
- **Медиатека:** библиотека и хранилища.
- **Загрузки:** предложить треки, мои предложения и очередь на рассмотрение.
- **Система:** обновления, конфигурация, настройки и журнал.
На компьютере подразделы можно сворачивать; браузер запоминает выбор. Подраздел текущей страницы
раскрывается при переходе. На телефоне группы показаны заголовками в меню «Ещё». Видимость страниц зависит от прав.
«Администрирование → Пользователи». «Администрирование → Пользователи».
| Роль | Что может | | Роль | Что может |
+106
View File
@@ -0,0 +1,106 @@
# Project Review — 2026-10-24 (1)
## Вывод и область проверки
Модульный монолит, contract-first API и общий core соответствуют масштабу проекта. После предыдущего рефакторинга у library/uploads/player появились полезные границы. Перестраивать проект целиком ради SOLID оснований нет. Приоритет — изоляция асинхронных операций при смене аккаунта, авторизация записей и согласованность состояния рекомендаций.
Проверены карта документации и архитектура, локальные AGENTS, выбранные потоки auth, playback/events, wave, social/listening, uploads/placement, library/schedule, web persistence/dialogs, Android offline/events/downloads/onboarding, desktop proxy, Python wave/API и текущий diff. Это статическое ревью перечисленных потоков с целевыми тестами, а не построчная проверка каждого файла или полный security-аудит. Физический Android, реальные аудиопотоки, NFS и производительность под нагрузкой не проверялись. Замечания ниже подтверждены чтением кода; отдельные новые воспроизводящие тесты для них в этой работе не добавлялись.
## Prior Reviews Summary
В каталоге были два отчёта: `2026-10-22-review-1.md` и `2026-10-21-review-1.md`. Все 17 задач отмечены `[x]`; закрытые задачи повторно не открывались.
### Still Open (carried forward)
Нет. Перенесено: 0.
### Resolved Since Last Review
Нет ранее открытых задач для перепроверки. Подтверждено новых закрытий: 0.
## Phase 1: Code Quality
### SOLID
**F7, P3 — WaveService собирает инфраструктуру и выполняет сценарий.** Конструктор сам создаёт IndexHolder, SessionStore и RankerHolder, `batch` открывает PostgreSQL-соединения, загружает профиль, меняет Redis-сессию, вызывает алгоритм и пишет обучающие признаки. Изолированная проверка сценария требует подмены конкретных инфраструктурных деталей. Передавать узкие порты каталога/профиля, сессии и impression sink из `_serve`, оставив SQL/Redis в адаптерах. Не нужно оборачивать каждую математическую функцию интерфейсом. Источник: `services/recommender/pipu_rec/wave/service.py:58`, `:79`, `:112`; composition root: `pipu_rec/cli.py:_serve`.
Доказанных нарушений LSP в просмотренных реализациях не найдено. Остаточная длина Player и наличие конкретных адаптеров в composition root сами по себе не считаются дефектами. Закрытые замечания предыдущего SOLID-ревью не дублируются.
### Performance
**F5, P2 — размер исключений растёт без ограничения и нарушает внутренний контракт.** Go `servedIDs` загружает все показы сессии и передаёт весь массив как `exclude_ids`, а Python BatchRequest ограничивает его 1000 элементами. После превышения 1000 показов запросы этой сессии получают 422, и Go каждый раз переходит на fallback. Это подтверждённый конфликт границ, а не результат нагрузочного замера. Согласовать bounded history/хранение исключений на стороне рекомендателя, сохранив политику неповторов. Источники: `services/api/internal/wave/wave.go:291`, `:349`; `services/recommender/pipu_rec/wave/service.py:40`.
Иных измеренных проблем производительности не установлено.
### Correctness & Bugs
**F1, P1 — незавершённая загрузка Android продолжает работу в новом аккаунте.** `attach` меняет `this.owner/server` и очищает active; `run` захватывает старую session, но после сетевого await строит путь через изменяемый `this.file` и URL через `this.abs`, затем без проверки поколения снова вставляет DownloadResumable в active. Сценарий: медленный `/tracks/batch` → logout/смена сервера → старый ответ. Старая задача может писать в каталог нового владельца и занимать его очередь; её finally может удалить active новой задачи с тем же track ID. Проверка owner внутри `status` не защищает создание загрузки, progress callback и файловые операции. Захватывать контекст задачи (owner/server/пути), проверять поколение после await и удалять active только по идентичности задачи. Источник: `apps/mobile/src/offline/downloads.ts:84`, `:228`, `:235`, `:261`.
**F3, P2 — поздний refresh восстанавливает устаревшую авторизацию.** `doRefresh` без проверки версии вызывает `apply`; logout/signOut и новый login не инвалидируют уже отправленный refresh. Если успешный refresh был обработан сервером до logout, но его ответ задержался, он может после signOut вернуть локальный signed_in и записать старые токены в RefreshStore. Аналогично ответ старого refresh может перезаписать новый login. Нужны поколение auth-операций и отбрасывание устаревшего результата до изменения access/store/state. Источник: `packages/core/src/auth.ts:143`, `:159`, `:167`, `:197`.
### Code Quality
Отдельных задач по косметике, размеру файлов и magic numbers не создано: они уступают проверенным нарушениям поведения.
## Phase 2: Logical Consistency
### Domain & Application Layer
**F2, P1 — событие пользователя может изменить исход чужой wave-сессии.** Клиент передаёт произвольный UUID в context_id; Accept передаёт его в MarkImpression, где UPDATE ограничен только sessionID/trackID. Проверки `wave_sessions.user_id = p.UserID` нет. Зная UUID чужой сессии и трека, авторизованный клиент может записать like/dislike/skip в чужой показ, влияя на метрики и обучающие данные. UUID усложняет угадывание, но не заменяет авторизацию. Передавать userID и ограничивать запись владельцем сессии в той же SQL-операции; добавить тест изоляции двух пользователей. Источники: `services/api/internal/playback/events.go:75`, `services/api/internal/playback/store.go:354`; контракт `api/openapi.yaml:PlayEvent` разрешает context_id как строку.
### Data Flow
Сохранены положительные границы: core не импортирует платформенные реализации, uploads use cases отделены от pgx, read-проекции могут делать межмодульные JOIN. Целевая проверка `pnpm check:boundaries` проходит; это не доказательство отсутствия всех циклов импортов.
### State Management
**F4, P2 — drain реакций может перезаписать более свежий выбор.** `drain` читает snapshot pending_reactions и отправляет его без координации с `react`. Возможен порядок: drain прочитал старый like → react успешно записал новый dislike и удалил pending → drain позже отправил старый like. Условие `reaction IS ?` защищает удаление SQLite-строки, но не порядок запросов к серверу. Сериализовать операции по track ID либо добавить версию и согласовать запись на сервере. Источник: `apps/mobile/src/offline/events.ts:50`, `:65`, `:77`, `:82`.
**F6, P2 — параллельные запросы теряют обновления Redis-сессии волны.** API запускает batch через `asyncio.to_thread`; каждый batch отдельно делает get → меняет session → put. Два запроса одной сессии могут прочитать одинаковый snapshot, выбрать пересекающиеся треки и последней записью затереть feedback/served/recent_artists первого. PostgreSQL-обновление tracks_served не защищает Redis и выбор кандидатов. Нужна сериализация/версионирование всей операции по session_id с учётом нескольких процессов, а не только локальный thread lock. Источники: `services/recommender/pipu_rec/api/app.py:wave_batch`, `pipu_rec/wave/service.py:84`, `:106`, `pipu_rec/wave/store.py:SessionStore`.
### Consistency
Конфликт внутреннего контракта Go/Python описан как F5; отдельная дублирующая задача не создаётся.
## Phase 3: UI/UX
### Usability
**F8, P2 — Android молча пропускает onboarding при ошибке его загрузки.** После завершения запроса с ошибкой `query.isPending` становится false, data отсутствует, и `!query.data?.required` возвращает основной интерфейс без ошибки и retry. Для нового пользователя временный сбой выглядит как отсутствие настройки рекомендаций. Разделить unknown/error и подтверждённое required=false; предусмотреть retry и осознанный вход в offline-режим без блокирования скачанной музыки. Источник: `apps/mobile/src/ui/Onboarding.tsx:29`.
### Visual & Consistency
Новых подтверждённых визуальных замечаний нет. Старые закрытые mobile-web задачи не переоткрывались. В этой работе браузер использовался для функциональных E2E; полного визуального аудита всех экранов не было.
### Interaction & Feedback
В текущих висящих изменениях завершён lifecycle listening: закрытие диалога после отправки, отсутствие стрима до admission, отдельная ошибка listening, явный запуск на втором устройстве ведущего, защита от позднего GET после выхода и сохранение refresh во время запроса. Дополнительно исправлено освобождение пустой скрытой комнаты после отмены/истечения последнего запроса; музыка источника продолжает играть. Эти исправленные в рамках сдачи дефекты не входят в открытые Tasks.
### Accessibility
У просмотренного web Dialog есть именование, focus trap, Escape и возврат фокуса; у Android Field/Button есть доступные имена/роли. Новых доказанных accessibility-дефектов не заявлено. Проверка screen reader и всех контрастов не проводилась.
## Tasks
Новых открытых задач: 8. Перенесено: 0. Ранее открытых, исправленных после прошлых ревью: 0. Исправление всех найденных замечаний не входило в просьбу завершить уже висящие изменения listening.
- [ ] 1. [Bug] [P1, F1] Изолировать Download jobs по поколению owner/server, включая URL, пути, progress и active cleanup — `apps/mobile/src/offline/downloads.ts` line 261.
- [ ] 2. [Logic] [P1, F2] Проверять владельца wave-сессии при MarkImpression и покрыть изоляцию пользователей — `services/api/internal/playback/store.go` line 354.
- [ ] 3. [Bug] [P2, F3] Инвалидировать поздние refresh/apply после logout и нового login — `packages/core/src/auth.ts` line 197.
- [ ] 4. [Bug] [P2, F4] Согласовать порядок drain и новых реакций по track ID — `apps/mobile/src/offline/events.ts` line 82.
- [ ] 5. [Performance] [P2, F5] Согласовать bounded exclude history Go/Python, исключить постоянный fallback после 1000 показов — `services/api/internal/wave/wave.go` line 349.
- [ ] 6. [Logic] [P2, F6] Защитить read-modify-write wave-сессии от параллельных batch — `services/recommender/pipu_rec/wave/service.py` line 84.
- [ ] 7. [UX] [P2, F8] Отделить ошибку onboarding от required=false и дать retry/offline-путь — `apps/mobile/src/ui/Onboarding.tsx` line 29.
- [ ] 8. [SOLID] [P3, F7] Передавать узкие IO-порты в WaveService из composition root — `services/recommender/pipu_rec/wave/service.py` line 58.
## Проверки и Git
- До изменений этого ревью: HEAD `cb22f15`; после успешного `git fetch origin` — HEAD и origin/main совпадали, ahead=0, behind=0.
- В рабочем дереве изначально: 13 изменённых tracked-файлов listening/тестов/переводов/docs, новый `apps/web/e2e/listening.spec.ts`, а также отдельные `scripts/share-stats.py`, `artifacts/wave-palette/`, `services/api/api.exe`. Последние три не менялись и не включаются в коммит listening.
- Core: 69 тестов прошли, в том числе новый тест освобождения пустой комнаты.
- Web: App + player/listening — 51 тест прошёл в итоговом прогоне. В первом запуске при параллельных проверках упал lossless-hint test; изолированно и в повторном прогоне проходит. Стабильность этого теста не считается доказанной одним повтором.
- Mobile: 6 тестов прошли. Web/mobile/core typecheck и i18n check прошли (1111 ключей × 2 языка).
- `pnpm check:boundaries` прошёл. `git diff --check` прошёл.
- Go: unit-тесты social/playback/wave и vet social прошли. Integration `TestActivityRoomsAndChatPrivacy` собирается, но **SKIP**: TEST_DATABASE_URL не задан. Docker daemon недоступен. Транзакционные сценарии на реальной БД этой проверкой не подтверждены.
- Playwright listening: 4 сценария прошли. Используется fake API; ошибки аудиопрокси ECONNREFUSED в сценариях не являются проверкой воспроизведения настоящего аудио.
- Публичный OpenAPI не менялся, генерация не требуется. Реальный Android/background и PostgreSQL остаются ограничениями проверки.
+8
View File
@@ -89,6 +89,14 @@ React Query; Android останавливает polling при уходе при
«Подключиться» отправляет запрос хозяину и оставляет управление у него. «Подключиться» отправляет запрос хозяину и оставляет управление у него.
«Пригласить на мой стрим» создаёт собственную комнату: приглашённый становится слушателем. «Пригласить на мой стрим» создаёт собственную комнату: приглашённый становится слушателем.
Один пользователь состоит в одной комнате; несколько слушателей могут подключиться к хозяину. Один пользователь состоит в одной комнате; несколько слушателей могут подключиться к хозяину.
Комната резервируется на время ожидания подтверждения. До принятия запроса
слушатель не становится участником, а баннер совместного прослушивания не показывается.
Исходное устройство ведущего поддерживает резервирование, не прерывая свою музыку.
После успешной отправки окно закрывается; ожидающий запрос можно отменить в разделе общения.
После отмены или истечения последнего запроса исходное устройство освобождает
пустую комнату при следующем опросе, не прерывая свою музыку.
Повторный запрос уже подключённого слушателя не создаёт нового ожидания.
Второе устройство ведущего не включает звук автоматически и при закрытии не завершает стрим.
Настройки приёма запросов/приглашений и автоматического принятия независимы, Настройки приёма запросов/приглашений и автоматического принятия независимы,
обе по умолчанию «Никто». Автопринятие применяется только внутри разрешённой аудитории. обе по умолчанию «Никто». Автопринятие применяется только внутри разрешённой аудитории.
Дополнительная настройка управляет запуском звука при подключении. Дополнительная настройка управляет запуском звука при подключении.
+44
View File
@@ -31,4 +31,48 @@ describe('shared listening',()=>{
it('pauses on connection loss and on room termination',async()=>{ it('pauses on connection loss and on room termination',async()=>{
const e=setup();await e.coordinator.poll(true);e.get.mockRejectedValueOnce(new TypeError('offline'));e.advance(11000);await e.coordinator.poll(true);expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({disconnected:true}));e.setState({...e.state,room:null});await e.coordinator.poll(true);expect(e.coordinator.locked).toBe(false);expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null}));e.coordinator.dispose(); const e=setup();await e.coordinator.poll(true);e.get.mockRejectedValueOnce(new TypeError('offline'));e.advance(11000);await e.coordinator.poll(true);expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({disconnected:true}));e.setState({...e.state,room:null});await e.coordinator.poll(true);expect(e.coordinator.locked).toBe(false);expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null}));e.coordinator.dispose();
}); });
it('keeps a reserved source room alive without announcing a stream before approval',async()=>{
const e=setup(host,device);e.setState({...e.state,room:{...room,participants:[{id:host,name:'Host'}]},requests:[{id:'request',owner_id:host,sender_id:guest,sender_name:'Guest',recipient_id:host,kind:'join',expires_at:'2026-10-23T00:02:00Z'}]});
await e.coordinator.poll(true);
expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null,locked:false}));
expect(e.put).toHaveBeenCalled();expect(e.adapter.pause).not.toHaveBeenCalled();expect(e.adapter.apply).not.toHaveBeenCalled();e.coordinator.dispose();
});
it('releases an empty source reservation after its final request is cancelled or expires',async()=>{
const e=setup(host,device);e.setState({...e.state,room:{...room,participants:[{id:host,name:'Host'}]}});
await e.coordinator.poll(true);
expect(e.post).toHaveBeenCalledWith('/api/v1/me/social/listening/actions',expect.objectContaining({body:expect.objectContaining({action:'end',target_id:room.id})}));
expect(e.put).not.toHaveBeenCalled();expect(e.adapter.pause).not.toHaveBeenCalled();
expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null,locked:false}));
e.coordinator.dispose();expect(e.post).toHaveBeenCalledTimes(1);
});
it('does not take over another source device while its request is pending',async()=>{
const e=setup(host);e.setState({...e.state,room:{...room,participants:[{id:host,name:'Host'}]}});
await e.coordinator.poll(true);
expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null,locked:false}));
expect(e.adapter.apply).not.toHaveBeenCalled();expect(e.adapter.pause).not.toHaveBeenCalled();e.coordinator.dispose();expect(e.post).not.toHaveBeenCalled();
});
it('does not start a pending guest request with no admitted room',async()=>{
const e=setup();e.setState({...e.state,room:null,requests:[{id:'request',owner_id:host,sender_id:guest,sender_name:'Guest',recipient_id:host,kind:'join',expires_at:'2026-10-23T00:02:00Z'}]});
await e.coordinator.poll(true);expect(e.adapter.apply).not.toHaveBeenCalled();expect(e.coordinator.locked).toBe(false);e.coordinator.dispose();
});
it('requires explicit audio on a second owner device and does not end the source when it closes',async()=>{
const e=setup(host);await e.coordinator.poll(true);
expect(e.adapter.apply).toHaveBeenLastCalledWith(expect.objectContaining({playing:false}),3000,expect.any(Function));
e.coordinator.beginAudio();await vi.waitFor(()=>expect(e.adapter.apply).toHaveBeenLastCalledWith(expect.objectContaining({playing:true}),3000,expect.any(Function)));
e.coordinator.dispose();expect(e.post).not.toHaveBeenCalled();
});
it('discards a room lookup that arrives after a local exit',async()=>{
const e=setup();let resolve!:(value:{data:ListeningState;response:Response})=>void;
e.get.mockImplementationOnce(()=>new Promise(done=>{resolve=done;}));
const pending=e.coordinator.poll(true);e.coordinator.leaveLocally();resolve({data:e.state,response:new Response()});await pending;
expect(e.adapter.apply).not.toHaveBeenCalled();expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null}));e.coordinator.dispose();
});
it('queues a refresh requested while a state lookup is in flight',async()=>{
const e=setup();let resolve!:(value:{data:ListeningState;response:Response})=>void;
e.get.mockImplementationOnce(()=>new Promise(done=>{resolve=done;}));
const pending=e.coordinator.poll(true);e.coordinator.refresh();
e.setState({...e.state,room:null});resolve({data:{...e.state,room:{...room}},response:new Response()});await pending;
await vi.waitFor(()=>expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null})));
expect(e.get).toHaveBeenCalledTimes(2);e.coordinator.dispose();
});
}); });
+34 -7
View File
@@ -22,6 +22,7 @@ export class ListeningCoordinator {
private generation = 0; private generation = 0;
private sequence = 0; private sequence = 0;
private busy = false; private busy = false;
private refreshPending = false;
private closed = false; private closed = false;
private lastPoll = 0; private lastPoll = 0;
private lastSuccess = 0; private lastSuccess = 0;
@@ -46,9 +47,12 @@ export class ListeningCoordinator {
if (!room || !this.source(room)) return; if (!room || !this.source(room)) return;
const state = this.adapter.read(); const state = this.adapter.read();
const key = `${state.track?.id}:${state.playing}`; const key = `${state.track?.id}:${state.playing}`;
if (key !== this.key) { this.key = key; void this.poll(true); } if (key !== this.key) { this.key = key; this.refresh(); }
}
refresh() {
if (this.busy) { this.refreshPending = true; return; }
void this.poll(true);
} }
refresh() { void this.poll(true); }
allowJoining() { this.ignored.clear(); } allowJoining() { this.ignored.clear(); }
beginAudio() { if (this.snapshot?.room) this.enabled.add(this.snapshot.room.id); this.refresh(); } beginAudio() { if (this.snapshot?.room) this.enabled.add(this.snapshot.room.id); this.refresh(); }
leaveLocally() { leaveLocally() {
@@ -66,9 +70,10 @@ export class ListeningCoordinator {
if (this.closed || this.busy || (!force && !this.snapshot?.room && this.now() - this.lastPoll < 10_000)) return; if (this.closed || this.busy || (!force && !this.snapshot?.room && this.now() - this.lastPoll < 10_000)) return;
if (this.session.state?.status !== 'signed_in') return; if (this.session.state?.status !== 'signed_in') return;
this.busy = true; this.lastPoll = this.now(); this.busy = true; this.lastPoll = this.now();
const requestGeneration = this.generation;
try { try {
const state = unwrap(await this.session.client.GET('/api/v1/me/social/listening')); const state = unwrap(await this.session.client.GET('/api/v1/me/social/listening'));
if (this.closed) return; if (this.closed || requestGeneration !== this.generation) return;
this.lastSuccess = this.now(); this.lastSuccess = this.now();
const previous = this.snapshot?.room; const previous = this.snapshot?.room;
this.snapshot = state; this.snapshot = state;
@@ -80,14 +85,32 @@ export class ListeningCoordinator {
await this.session.client.POST('/api/v1/me/social/listening/actions', { body: { id: this.deviceId, action: 'leave', target_id: room.id } }); await this.session.client.POST('/api/v1/me/social/listening/actions', { body: { id: this.deviceId, action: 'leave', target_id: room.id } });
return; return;
} }
// The server reserves a room while a request is pending. Only admitted
// participants turn it into a shared session; keep the source heartbeat
// alive without displaying a stream or taking over another device.
const shared = room.participants.some(person => person.id !== room.owner_id);
if (!shared && !this.source(room)) {
if (this.locked) { this.generation++; this.adapter.pause(); }
this.emit(null);
return;
}
if (this.source(room)) { if (this.source(room)) {
this.emit(room); // A cancelled or expired reservation must not keep an invisible room
// alive forever and prevent its owner from joining another stream.
if (!shared && !state.requests.some(request => request.owner_id === room.owner_id)) {
unwrap(await this.session.client.POST('/api/v1/me/social/listening/actions', { body: { id: this.deviceId, action: 'end', target_id: room.id } }));
this.snapshot = { ...state, room: null };
this.emit(null);
return;
}
this.emit(shared ? room : null);
const playback = this.adapter.read(); const playback = this.adapter.read();
unwrap(await this.session.client.PUT('/api/v1/me/social/listening/state', { body: { room_id: room.id, device_id: this.deviceId, sequence: ++this.sequence, track_id: playback.track?.id, playing: playback.playing && !!playback.track, position_ms: Math.max(0, Math.round(playback.positionMs)) } })); unwrap(await this.session.client.PUT('/api/v1/me/social/listening/state', { body: { room_id: room.id, device_id: this.deviceId, sequence: ++this.sequence, track_id: playback.track?.id, playing: playback.playing && !!playback.track, position_ms: Math.max(0, Math.round(playback.positionMs)) } }));
return; return;
} }
if (!this.locked) this.adapter.pause(); if (!this.locked) this.adapter.pause();
const needsStart = !state.preferences.autoplay && !this.enabled.has(room.id); const ownOtherDevice = room.owner_id === this.session.state.user.id;
const needsStart = (!state.preferences.autoplay || ownOtherDevice) && !this.enabled.has(room.id);
this.emit(room, needsStart); this.emit(room, needsStart);
const version = this.generation; const version = this.generation;
const elapsed = room.playing ? Math.max(0, Date.parse(state.server_time) - Date.parse(room.updated_at)) : 0; const elapsed = room.playing ? Math.max(0, Date.parse(state.server_time) - Date.parse(room.updated_at)) : 0;
@@ -95,7 +118,10 @@ export class ListeningCoordinator {
} catch { } catch {
if (this.closed) return; if (this.closed) return;
if (this.locked && this.now() - this.lastSuccess > 10_000) { this.adapter.pause(); this.emit(this.snapshot?.room ?? null, false, true); } if (this.locked && this.now() - this.lastSuccess > 10_000) { this.adapter.pause(); this.emit(this.snapshot?.room ?? null, false, true); }
} finally { this.busy = false; } } finally {
this.busy = false;
if (this.refreshPending && !this.closed) { this.refreshPending = false; this.refresh(); }
}
} }
dispose() { dispose() {
if (this.closed) return; if (this.closed) return;
@@ -104,7 +130,8 @@ export class ListeningCoordinator {
clients.get(this.session)?.delete(this); clients.get(this.session)?.delete(this);
const room = this.snapshot?.room; const room = this.snapshot?.room;
if (this.locked) this.adapter.pause(); if (this.locked) this.adapter.pause();
if (room) void this.session.client.POST('/api/v1/me/social/listening/actions', { body: { id: this.deviceId, action: this.source(room) ? 'end' : 'leave', target_id: room.id } }).catch(() => undefined); const ownAccount = this.session.state.status === 'signed_in' && room?.owner_id === this.session.state.user.id;
if (room && (this.source(room) || !ownAccount)) void this.session.client.POST('/api/v1/me/social/listening/actions', { body: { id: this.deviceId, action: this.source(room) ? 'end' : 'leave', target_id: room.id } }).catch(() => undefined);
} }
} }
+3
View File
@@ -50,6 +50,9 @@
"main": "Navigation", "main": "Navigation",
"home": "Home", "home": "Home",
"admin": "Admin", "admin": "Admin",
"admin_media": "Media library",
"admin_uploads": "Uploads",
"admin_system": "System",
"users": "Users", "users": "Users",
"audit": "Audit log", "audit": "Audit log",
"library": "Library", "library": "Library",
+1
View File
@@ -124,6 +124,7 @@
"target_changed": "The source changed after checking. Check for updates again." "target_changed": "The source changed after checking. Check for updates again."
}, },
"social": { "social": {
"listening_unavailable": "Shared listening is unavailable. This person does not accept your requests or invitations, or is no longer available. Request preferences are in the profile menu → Privacy and communication.",
"person_offline": "This person is offline. Try inviting them again when they connect", "person_offline": "This person is offline. Try inviting them again when they connect",
"invalid": "Check your message or action", "invalid": "Check your message or action",
"unavailable": "Chats are available to friends only. The friendship may have ended or the person is unavailable", "unavailable": "Chats are available to friends only. The friendship may have ended or the person is unavailable",
+3
View File
@@ -50,6 +50,9 @@
"main": "Навигация", "main": "Навигация",
"home": "Главная", "home": "Главная",
"admin": "Админка", "admin": "Админка",
"admin_media": "Медиатека",
"admin_uploads": "Загрузки",
"admin_system": "Система",
"users": "Пользователи", "users": "Пользователи",
"audit": "Журнал", "audit": "Журнал",
"library": "Библиотека", "library": "Библиотека",
+1
View File
@@ -124,6 +124,7 @@
"target_changed": "Источник изменился после проверки. Проверьте обновления ещё раз." "target_changed": "Источник изменился после проверки. Проверьте обновления ещё раз."
}, },
"social": { "social": {
"listening_unavailable": "Совместное прослушивание недоступно. Пользователь не принимает запросы или приглашения от вас либо больше недоступен. Настройки приёма находятся в меню профиля → «Приватность и общение».",
"person_offline": "Пользователь не в сети. Повторите приглашение, когда он подключится", "person_offline": "Пользователь не в сети. Повторите приглашение, когда он подключится",
"invalid": "Проверьте сообщение или действие", "invalid": "Проверьте сообщение или действие",
"unavailable": "Чат доступен только друзьям. Возможно, дружба завершена или пользователь недоступен", "unavailable": "Чат доступен только друзьям. Возможно, дружба завершена или пользователь недоступен",
@@ -0,0 +1,102 @@
package genius
import (
"context"
"errors"
"time"
"github.com/google/uuid"
)
const (
artistImageBatch = 20
artistImageRecheck = 30 * 24 * time.Hour
)
// artistSource — то, что нужно проходу по аватаркам от Genius-клиента.
type artistSource interface {
Search(ctx context.Context, query string) ([]Song, error)
Artist(ctx context.Context, id int64) (Artist, error)
}
// artistImage ищет исполнителя по имени: берёт песню, у которой primary_artist совпал с именем,
// и аватарку из неё либо из отдельного запроса артиста. Пустая строка — не нашли.
func artistImage(ctx context.Context, src artistSource, name string) (string, error) {
songs, err := src.Search(ctx, name)
if err != nil {
return "", err
}
for _, song := range songs {
if !sameArtist(song.Artist.Name, name) {
continue
}
if img := imageURL(song.Artist.ImageURL); img != "" {
return img, nil
}
if song.Artist.ID <= 0 {
continue
}
a, err := src.Artist(ctx, song.Artist.ID)
if err != nil {
var he *HTTPError
if errors.As(err, &he) && he.StatusCode == 404 {
continue
}
return "", err
}
return imageURL(a.ImageURL), nil
}
return "", nil
}
// fillArtistImages проходит по исполнителям без аватарки; каждого проверяет раз в artistImageRecheck.
func (s *Service) fillArtistImages(ctx context.Context) error {
var active bool
if err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM genius_state WHERE NOT paused AND cooldown_until<=now())`).Scan(&active); err != nil || !active {
return err
}
rows, err := s.pool.Query(ctx, `SELECT id, name FROM artists
WHERE image_path IS NULL AND genius_image_url IS NULL
AND (genius_image_checked_at IS NULL OR genius_image_checked_at < now()-$1::interval)
ORDER BY genius_image_checked_at NULLS FIRST LIMIT $2`, artistImageRecheck.String(), artistImageBatch)
if err != nil {
return err
}
type row struct {
id uuid.UUID
name string
}
var batch []row
for rows.Next() {
var r row
if err := rows.Scan(&r.id, &r.name); err != nil {
rows.Close()
return err
}
batch = append(batch, r)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
for _, r := range batch {
if ctx.Err() != nil {
return ctx.Err()
}
reqCtx, cancel := context.WithTimeout(ctx, time.Minute)
img, err := artistImage(reqCtx, s.client, r.name)
cancel()
if err != nil {
var he *HTTPError
if errors.As(err, &he) && he.StatusCode == 429 {
_, _ = s.pool.Exec(ctx, `UPDATE genius_state SET cooldown_until=GREATEST(cooldown_until,$1)`, time.Now().Add(retryDelay(he.RetryAfter)))
}
return err
}
if _, err := s.pool.Exec(ctx, `UPDATE artists SET genius_image_checked_at=now(),
genius_image_url=COALESCE(genius_image_url, NULLIF($2,'')) WHERE id=$1`, r.id, img); err != nil {
return err
}
}
return nil
}
+13 -1
View File
@@ -16,11 +16,23 @@ func normalize(s string) string {
return strings.Join(strings.FieldsFunc(strings.ToLower(s), func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsNumber(r) }), " ") return strings.Join(strings.FieldsFunc(strings.ToLower(s), func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsNumber(r) }), " ")
} }
// sameArtist сравнивает имя на Genius с нашим: у Genius часто «имя (транслитерация)»,
// поэтому скобочный хвост допустим.
func sameArtist(geniusName, name string) bool {
if normalize(geniusName) == normalize(name) {
return true
}
if i := strings.Index(geniusName, "("); i > 0 && strings.HasSuffix(strings.TrimSpace(geniusName), ")") {
return normalize(geniusName[:i]) == normalize(name)
}
return false
}
// Exact normalized identity; retain version qualifiers such as live/remix. // Exact normalized identity; retain version qualifiers such as live/remix.
func matchSong(songs []Song, artist, title string) (Song, bool) { func matchSong(songs []Song, artist, title string) (Song, bool) {
var matched Song var matched Song
for _, s := range songs { for _, s := range songs {
if normalize(s.Title) == normalize(title) && normalize(s.Artist.Name) == normalize(artist) { if normalize(s.Title) == normalize(title) && sameArtist(s.Artist.Name, artist) {
if matched.ID != 0 && matched.ID != s.ID { if matched.ID != 0 && matched.ID != s.ID {
return Song{}, false return Song{}, false
} }
@@ -95,3 +95,25 @@ func TestAnnotations(t *testing.T) {
require.True(t, notes[0].Verified) require.True(t, notes[0].Verified)
require.Equal(t, "https://genius.com/annotations/1", notes[0].Url) require.Equal(t, "https://genius.com/annotations/1", notes[0].Url)
} }
type fakeArtists struct {
songs []Song
artist Artist
}
func (f fakeArtists) Search(context.Context, string) ([]Song, error) { return f.songs, nil }
func (f fakeArtists) Artist(context.Context, int64) (Artist, error) { return f.artist, nil }
func TestArtistImage(t *testing.T) {
ctx := context.Background()
src := fakeArtists{songs: []Song{
{Artist: Artist{ID: 1, Name: "Other", ImageURL: "https://images.genius.com/other.jpg"}},
{Artist: Artist{ID: 2, Name: "Некич (Nekich)"}},
}, artist: Artist{ID: 2, ImageURL: "https://images.genius.com/nekich.jpg"}}
img, err := artistImage(ctx, src, "некич")
require.NoError(t, err)
require.Equal(t, "https://images.genius.com/nekich.jpg", img)
img, err = artistImage(ctx, src, "Unknown")
require.NoError(t, err)
require.Empty(t, img)
}
+3
View File
@@ -48,6 +48,9 @@ func (s *Service) Run(ctx context.Context) {
if err := s.drain(ctx); err != nil && ctx.Err() == nil { if err := s.drain(ctx); err != nil && ctx.Err() == nil {
s.log.Error("genius worker", "err", err) s.log.Error("genius worker", "err", err)
} }
if err := s.fillArtistImages(ctx); err != nil && ctx.Err() == nil {
s.log.Error("genius artist images", "err", err)
}
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return
@@ -32,6 +32,9 @@ func TestSplitArtists(t *testing.T) {
assert.Equal(t, []string{"main:Simon & Garfunkel"}, names(SplitArtists("Simon & Garfunkel", known))) assert.Equal(t, []string{"main:Simon & Garfunkel"}, names(SplitArtists("Simon & Garfunkel", known)))
assert.Equal(t, []string{"main:Tom", "main:Jerry"}, names(SplitArtists("Tom & Jerry", known))) assert.Equal(t, []string{"main:Tom", "main:Jerry"}, names(SplitArtists("Tom & Jerry", known)))
assert.Equal(t, []string{"main:A", "main:B"}, names(SplitArtists("A; B", nil))) assert.Equal(t, []string{"main:A", "main:B"}, names(SplitArtists("A; B", nil)))
assert.Equal(t, []string{"main:A", "main:B", "main:C"}, names(SplitArtists("A, B, C", nil)))
assert.Equal(t, []string{"main:A", "main:B"}, names(SplitArtists("A, B", func(string) bool { return true })), "already glued name is still split")
assert.Equal(t, []string{"main:Tyler, the Creator"}, names(SplitArtists("Tyler, the Creator", nil)))
assert.Equal(t, []string{"main:A", "feat:B", "feat:C"}, names(SplitArtists("A feat. B & C", nil))) assert.Equal(t, []string{"main:A", "feat:B", "feat:C"}, names(SplitArtists("A feat. B & C", nil)))
assert.Equal(t, []string{"main:A"}, names(SplitArtists("A / a", nil)), "duplicates by key are dropped") assert.Equal(t, []string{"main:A"}, names(SplitArtists("A / a", nil)), "duplicates by key are dropped")
assert.Empty(t, SplitArtists(" ", nil)) assert.Empty(t, SplitArtists(" ", nil))
@@ -85,13 +85,15 @@ func SplitArtists(s string, known func(key string) bool) []ArtistCredit {
} }
} }
for _, part := range splitAny(main, artistSeparators) { for _, part := range splitAny(main, artistSeparators) {
if strings.Contains(part, " & ") && (known == nil || !known(Key(part))) { for _, seg := range splitComma(part) {
for _, p := range strings.Split(part, " & ") { if strings.Contains(seg, " & ") && (known == nil || !known(Key(seg))) {
for _, p := range strings.Split(seg, " & ") {
add(p, "main") add(p, "main")
} }
continue continue
} }
add(part, "main") add(seg, "main")
}
} }
for _, part := range splitAny(feat, append(artistSeparators, " & ", ", ")) { for _, part := range splitAny(feat, append(artistSeparators, " & ", ", ")) {
add(part, "feat") add(part, "feat")
@@ -99,6 +101,20 @@ func SplitArtists(s string, known func(key string) bool) []ArtistCredit {
return out return out
} }
// splitComma делит по «, » без оглядки на библиотеку: иначе уже склеенное имя («A, B») навсегда
// остаётся «известным». Продолжение «the …» — часть имени («Tyler, the Creator»).
func splitComma(s string) []string {
var out []string
for _, p := range strings.Split(s, ", ") {
if n := len(out); n > 0 && strings.HasPrefix(strings.ToLower(p), "the ") {
out[n-1] += ", " + p
continue
}
out = append(out, p)
}
return out
}
func splitAny(s string, seps []string) []string { func splitAny(s string, seps []string) []string {
parts := []string{s} parts := []string{s}
for _, sep := range seps { for _, sep := range seps {
+11 -3
View File
@@ -422,7 +422,13 @@ func (sc *Scanner) apply(ctx context.Context, src Source, patterns []meta.Patter
if m.Album == "" { if m.Album == "" {
m.Album = UnknownAlbum m.Album = UnknownAlbum
} }
albumArtist, err := sc.store.EnsureArtist(ctx, m.AlbumArtist) known := func(k string) bool { return sc.store.ArtistKeyExists(ctx, k) }
// Альбом принадлежит одному исполнителю: у составного «A, B» это первый основной.
albumArtistName := m.AlbumArtist
if credits := meta.SplitArtists(m.AlbumArtist, known); len(credits) > 0 {
albumArtistName = credits[0].Name
}
albumArtist, err := sc.store.EnsureArtist(ctx, albumArtistName)
if err != nil { if err != nil {
return false, err return false, err
} }
@@ -437,7 +443,9 @@ func (sc *Scanner) apply(ctx context.Context, src Source, patterns []meta.Patter
if err != nil { if err != nil {
return false, err return false, err
} }
if !dup { // Тот же файл, перечитанный в тот же трек, заново разбирает исполнителей: правила разбора меняются.
sameTrack := dup && r.oldTrack != nil && *r.oldTrack == trackID
if !dup || sameTrack {
// Перечитанный файл сохраняет свой трек (лайки, история); новый — получает новый. // Перечитанный файл сохраняет свой трек (лайки, история); новый — получает новый.
if r.oldTrack != nil { if r.oldTrack != nil {
trackID = *r.oldTrack trackID = *r.oldTrack
@@ -445,7 +453,7 @@ func (sc *Scanner) apply(ctx context.Context, src Source, patterns []meta.Patter
if trackID, err = sc.store.SaveTrack(ctx, trackID, in); err != nil { if trackID, err = sc.store.SaveTrack(ctx, trackID, in); err != nil {
return false, err return false, err
} }
credits := meta.SplitArtists(m.Artist, func(k string) bool { return sc.store.ArtistKeyExists(ctx, k) }) credits := meta.SplitArtists(m.Artist, known)
ids := make([]uuid.UUID, 0, len(credits)) ids := make([]uuid.UUID, 0, len(credits))
roles := make([]string, 0, len(credits)) roles := make([]string, 0, len(credits))
for _, c := range credits { for _, c := range credits {
@@ -85,6 +85,18 @@ func TestActivityRoomsAndChatPrivacy(t *testing.T) {
require.NoError(t, s.publishActivity(ctx, owner, activity)) require.NoError(t, s.publishActivity(ctx, owner, activity))
join := gen.ListeningAction{Id: uuid.New(), Action: "join", TargetId: owner} join := gen.ListeningAction{Id: uuid.New(), Action: "join", TargetId: owner}
require.Error(t, s.listeningAction(ctx, friend, join)) // default nobody require.Error(t, s.listeningAction(ctx, friend, join)) // default nobody
_, e = pool.Exec(ctx, `UPDATE users SET listening_requests='friends',listening_auto_accept='nobody' WHERE id=$1`, owner)
require.NoError(t, e)
require.NoError(t, s.listeningAction(ctx, friend, join))
pendingJoin, e := s.listeningState(ctx, friend)
require.NoError(t, e)
require.Nil(t, pendingJoin.Room, "sending a request is not admission")
require.Len(t, pendingJoin.Requests, 1)
pendingHost, e := s.listeningState(ctx, owner)
require.NoError(t, e)
require.Len(t, pendingHost.Room.Participants, 1)
require.NoError(t, s.listeningAction(ctx, friend, gen.ListeningAction{Id: uuid.New(), Action: "cancel", TargetId: join.Id}))
join.Id = uuid.New()
_, e = pool.Exec(ctx, `UPDATE users SET listening_requests='friends',listening_auto_accept='everyone' WHERE id=$1`, owner) _, e = pool.Exec(ctx, `UPDATE users SET listening_requests='friends',listening_auto_accept='everyone' WHERE id=$1`, owner)
require.NoError(t, e) require.NoError(t, e)
require.Error(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "join", TargetId: owner})) require.Error(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "join", TargetId: owner}))
@@ -94,6 +106,12 @@ func TestActivityRoomsAndChatPrivacy(t *testing.T) {
require.NotNil(t, state.Room) require.NotNil(t, state.Room)
require.Equal(t, owner, state.Room.OwnerId) require.Equal(t, owner, state.Room.OwnerId)
require.Len(t, state.Room.Participants, 2) require.Len(t, state.Room.Participants, 2)
// Reopening the dialog must not create another pending request for an
// already admitted listener, even with a new action ID.
require.NoError(t, s.listeningAction(ctx, friend, gen.ListeningAction{Id: uuid.New(), Action: "join", TargetId: owner}))
state, e = s.listeningState(ctx, friend)
require.NoError(t, e)
require.Empty(t, state.Requests)
hostctx := auth.WithPrincipal(ctx, auth.Principal{UserID: owner}) hostctx := auth.WithPrincipal(ctx, auth.Principal{UserID: owner})
badctx := auth.WithPrincipal(ctx, auth.Principal{UserID: friend}) badctx := auth.WithPrincipal(ctx, auth.Principal{UserID: friend})
input := gen.ListeningRoomInput{RoomId: state.Room.Id, DeviceId: device, Sequence: 1, Playing: false, TrackId: &track, PositionMs: 3000} input := gen.ListeningRoomInput{RoomId: state.Room.Id, DeviceId: device, Sequence: 1, Playing: false, TrackId: &track, PositionMs: 3000}
@@ -113,11 +131,50 @@ func TestActivityRoomsAndChatPrivacy(t *testing.T) {
require.NoError(t, e) require.NoError(t, e)
require.Len(t, state.Requests, 1) require.Len(t, state.Requests, 1)
require.Nil(t, state.Room) require.Nil(t, state.Room)
reserved, e := s.listeningState(ctx, owner)
require.NoError(t, e)
require.NotNil(t, reserved.Room)
require.Len(t, reserved.Room.Participants, 1, "pending invite must not admit the guest")
require.NoError(t, s.listeningAction(ctx, owner, invite), "network retry is idempotent")
duplicate := invite
duplicate.Id = uuid.New()
require.ErrorContains(t, s.listeningAction(ctx, owner, duplicate), "listening_pending")
require.Error(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "cancel", TargetId: invite.Id}))
require.NoError(t, s.listeningAction(ctx, owner, gen.ListeningAction{Id: uuid.New(), Action: "cancel", TargetId: invite.Id}))
state, e = s.listeningState(ctx, other)
require.NoError(t, e)
require.Empty(t, state.Requests)
require.Nil(t, state.Room)
invite.Id = uuid.New()
require.NoError(t, s.listeningAction(ctx, owner, invite))
_, e = pool.Exec(ctx, `UPDATE listening_requests SET expires_at=now()-interval '1 second' WHERE id=$1`, invite.Id)
require.NoError(t, e)
require.ErrorContains(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "accept", TargetId: invite.Id}), "listening_expired")
state, e = s.listeningState(ctx, other)
require.NoError(t, e)
require.Empty(t, state.Requests)
require.Nil(t, state.Room)
invite.Id = uuid.New()
require.NoError(t, s.listeningAction(ctx, owner, invite))
require.NoError(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "decline", TargetId: invite.Id}))
state, e = s.listeningState(ctx, other)
require.NoError(t, e)
require.Empty(t, state.Requests)
require.Nil(t, state.Room)
invite.Id = uuid.New()
require.NoError(t, s.listeningAction(ctx, owner, invite))
_, e = pool.Exec(ctx, `UPDATE users SET listening_requests='nobody' WHERE id=$1`, other)
require.NoError(t, e)
require.ErrorContains(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "accept", TargetId: invite.Id}), "listening_unavailable")
_, e = pool.Exec(ctx, `UPDATE users SET listening_requests='everyone' WHERE id=$1`, other)
require.NoError(t, e)
require.Error(t, s.listeningAction(ctx, friend, gen.ListeningAction{Id: uuid.New(), Action: "accept", TargetId: invite.Id})) require.Error(t, s.listeningAction(ctx, friend, gen.ListeningAction{Id: uuid.New(), Action: "accept", TargetId: invite.Id}))
require.NoError(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "accept", TargetId: invite.Id})) require.NoError(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "accept", TargetId: invite.Id}))
state, e = s.listeningState(ctx, other) state, e = s.listeningState(ctx, other)
require.NoError(t, e) require.NoError(t, e)
require.Equal(t, owner, state.Room.OwnerId) require.Equal(t, owner, state.Room.OwnerId)
require.Len(t, state.Room.Participants, 2)
require.ErrorContains(t, s.listeningAction(ctx, friend, gen.ListeningAction{Id: uuid.New(), Action: "invite", TargetId: other, DeviceId: &device, TrackId: &track}), "listening_busy")
_, e = pool.Exec(ctx, `UPDATE listening_rooms SET updated_at=now()-interval '31 seconds' WHERE id=$1`, state.Room.Id) _, e = pool.Exec(ctx, `UPDATE listening_rooms SET updated_at=now()-interval '31 seconds' WHERE id=$1`, state.Room.Id)
require.NoError(t, e) require.NoError(t, e)
state, e = s.listeningState(ctx, other) state, e = s.listeningState(ctx, other)
+16 -6
View File
@@ -17,6 +17,9 @@ func audienceAllows(policy string, friends bool) bool {
func validAudience(v gen.ActivityVisibility) bool { func validAudience(v gen.ActivityVisibility) bool {
return v == "nobody" || v == "friends" || v == "everyone" return v == "nobody" || v == "friends" || v == "everyone"
} }
func listeningDenied() error {
return apperr.Forbidden("social.listening_unavailable", "shared listening is not permitted")
}
func (h *Handler) SetListeningPreferences(ctx context.Context, r gen.SetListeningPreferencesRequestObject) (gen.SetListeningPreferencesResponseObject, error) { func (h *Handler) SetListeningPreferences(ctx context.Context, r gen.SetListeningPreferencesRequestObject) (gen.SetListeningPreferencesResponseObject, error) {
id, e := userID(ctx) id, e := userID(ctx)
if e != nil { if e != nil {
@@ -75,7 +78,7 @@ func (h *Handler) PublishListeningState(ctx context.Context, r gen.PublishListen
return nil, e return nil, e
} }
if !own { if !own {
return nil, denied() return nil, listeningDenied()
} }
} }
return gen.PublishListeningState204Response{}, nil return gen.PublishListeningState204Response{}, nil
@@ -147,7 +150,7 @@ func listeningPolicy(ctx context.Context, tx pgx.Tx, recipient, sender uuid.UUID
COALESCE(social_seen_at>now()-interval '45 seconds',false) AND EXISTS(SELECT 1 FROM users WHERE id=$2 AND social_seen_at>now()-interval '45 seconds') COALESCE(social_seen_at>now()-interval '45 seconds',false) AND EXISTS(SELECT 1 FROM users WHERE id=$2 AND social_seen_at>now()-interval '45 seconds')
FROM users WHERE id=$1`, recipient, sender).Scan(&policy, &automatic, &friends, &blocked, &online) FROM users WHERE id=$1`, recipient, sender).Scan(&policy, &automatic, &friends, &blocked, &online)
if errors.Is(e, pgx.ErrNoRows) { if errors.Is(e, pgx.ErrNoRows) {
return false, false, denied() return false, false, listeningDenied()
} }
if e != nil { if e != nil {
return false, false, e return false, false, e
@@ -164,7 +167,7 @@ func admitListening(ctx context.Context, tx pgx.Tx, request, room, guest, recipi
return e return e
} }
if !allowed { if !allowed {
return denied() return listeningDenied()
} }
result, e := tx.Exec(ctx, `INSERT INTO listening_members(user_id,room_id) VALUES($1,$2) ON CONFLICT(user_id) DO UPDATE SET last_seen=now() WHERE listening_members.room_id=EXCLUDED.room_id`, guest, room) result, e := tx.Exec(ctx, `INSERT INTO listening_members(user_id,room_id) VALUES($1,$2) ON CONFLICT(user_id) DO UPDATE SET last_seen=now() WHERE listening_members.room_id=EXCLUDED.room_id`, guest, room)
if e != nil { if e != nil {
@@ -199,7 +202,7 @@ func (s *Store) listeningAction(ctx context.Context, id uuid.UUID, a gen.Listeni
return e return e
} }
if a.Action == "end" && owner != id { if a.Action == "end" && owner != id {
return denied() return listeningDenied()
} }
if owner == id { if owner == id {
_, e = tx.Exec(ctx, `DELETE FROM listening_rooms WHERE id=$1`, a.TargetId) _, e = tx.Exec(ctx, `DELETE FROM listening_rooms WHERE id=$1`, a.TargetId)
@@ -217,7 +220,7 @@ func (s *Store) listeningAction(ctx context.Context, id uuid.UUID, a gen.Listeni
return e return e
} }
if (a.Action == "cancel" && sender != id) || (a.Action != "cancel" && recipient != id) { if (a.Action == "cancel" && sender != id) || (a.Action != "cancel" && recipient != id) {
return denied() return listeningDenied()
} }
if status != "pending" { if status != "pending" {
return nil return nil
@@ -257,7 +260,7 @@ func (s *Store) listeningAction(ctx context.Context, id uuid.UUID, a gen.Listeni
return err return err
} }
if !permitted { if !permitted {
return denied() return listeningDenied()
} }
var repeated bool var repeated bool
if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM listening_requests WHERE id=$1 AND sender_id=$2 AND owner_id=$3 AND guest_id=$4 AND kind=$5)`, a.Id, id, owner, guest, a.Action).Scan(&repeated); e != nil { if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM listening_requests WHERE id=$1 AND sender_id=$2 AND owner_id=$3 AND guest_id=$4 AND kind=$5)`, a.Id, id, owner, guest, a.Action).Scan(&repeated); e != nil {
@@ -273,6 +276,13 @@ func (s *Store) listeningAction(ctx context.Context, id uuid.UUID, a gen.Listeni
if busy { if busy {
return apperr.Conflict("social.listening_busy", "already in another room") return apperr.Conflict("social.listening_busy", "already in another room")
} }
var alreadyJoined bool
if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM listening_members m JOIN listening_rooms r ON r.id=m.room_id WHERE m.user_id=$1 AND r.owner_id=$2)`, guest, owner).Scan(&alreadyJoined); e != nil {
return e
}
if alreadyJoined {
return tx.Commit(ctx)
}
var recent int var recent int
if e = tx.QueryRow(ctx, `SELECT count(*) FROM listening_requests WHERE sender_id=$1 AND created_at>now()-interval '1 minute'`, id).Scan(&recent); e != nil { if e = tx.QueryRow(ctx, `SELECT count(*) FROM listening_requests WHERE sender_id=$1 AND created_at>now()-interval '1 minute'`, id).Scan(&recent); e != nil {
return e return e
@@ -0,0 +1,4 @@
-- +goose Up
ALTER TABLE artists ADD COLUMN genius_image_checked_at timestamptz;
-- +goose Down
ALTER TABLE artists DROP COLUMN genius_image_checked_at;