Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66d3897f66 | ||
|
|
17bb0e1846 | ||
|
|
17c85493b7 | ||
|
|
75f9c57eca | ||
|
|
8a007be036 | ||
|
|
f77148df2c | ||
|
|
139dd226fa | ||
|
|
795bb7de46 | ||
|
|
11b890c2e7 | ||
|
|
59983ed1b3 |
No files matched your search
@@ -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')} />
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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 } }));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -14,6 +14,15 @@
|
|||||||
|
|
||||||
## 2. Пользователи и роли
|
## 2. Пользователи и роли
|
||||||
|
|
||||||
|
В меню «Админка» пользователи доступны отдельным пунктом, остальные страницы собраны в подразделы:
|
||||||
|
|
||||||
|
- **Медиатека:** библиотека и хранилища.
|
||||||
|
- **Загрузки:** предложить треки, мои предложения и очередь на рассмотрение.
|
||||||
|
- **Система:** обновления, конфигурация, настройки и журнал.
|
||||||
|
|
||||||
|
На компьютере подразделы можно сворачивать; браузер запоминает выбор. Подраздел текущей страницы
|
||||||
|
раскрывается при переходе. На телефоне группы показаны заголовками в меню «Ещё». Видимость страниц зависит от прав.
|
||||||
|
|
||||||
«Администрирование → Пользователи».
|
«Администрирование → Пользователи».
|
||||||
|
|
||||||
| Роль | Что может |
|
| Роль | Что может |
|
||||||
|
|||||||
@@ -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 остаются ограничениями проверки.
|
||||||
@@ -89,6 +89,14 @@ React Query; Android останавливает polling при уходе при
|
|||||||
«Подключиться» отправляет запрос хозяину и оставляет управление у него.
|
«Подключиться» отправляет запрос хозяину и оставляет управление у него.
|
||||||
«Пригласить на мой стрим» создаёт собственную комнату: приглашённый становится слушателем.
|
«Пригласить на мой стрим» создаёт собственную комнату: приглашённый становится слушателем.
|
||||||
Один пользователь состоит в одной комнате; несколько слушателей могут подключиться к хозяину.
|
Один пользователь состоит в одной комнате; несколько слушателей могут подключиться к хозяину.
|
||||||
|
Комната резервируется на время ожидания подтверждения. До принятия запроса
|
||||||
|
слушатель не становится участником, а баннер совместного прослушивания не показывается.
|
||||||
|
Исходное устройство ведущего поддерживает резервирование, не прерывая свою музыку.
|
||||||
|
После успешной отправки окно закрывается; ожидающий запрос можно отменить в разделе общения.
|
||||||
|
После отмены или истечения последнего запроса исходное устройство освобождает
|
||||||
|
пустую комнату при следующем опросе, не прерывая свою музыку.
|
||||||
|
Повторный запрос уже подключённого слушателя не создаёт нового ожидания.
|
||||||
|
Второе устройство ведущего не включает звук автоматически и при закрытии не завершает стрим.
|
||||||
Настройки приёма запросов/приглашений и автоматического принятия независимы,
|
Настройки приёма запросов/приглашений и автоматического принятия независимы,
|
||||||
обе по умолчанию «Никто». Автопринятие применяется только внутри разрешённой аудитории.
|
обе по умолчанию «Никто». Автопринятие применяется только внутри разрешённой аудитории.
|
||||||
Дополнительная настройка управляет запуском звука при подключении.
|
Дополнительная настройка управляет запуском звука при подключении.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -50,6 +50,9 @@
|
|||||||
"main": "Навигация",
|
"main": "Навигация",
|
||||||
"home": "Главная",
|
"home": "Главная",
|
||||||
"admin": "Админка",
|
"admin": "Админка",
|
||||||
|
"admin_media": "Медиатека",
|
||||||
|
"admin_uploads": "Загрузки",
|
||||||
|
"admin_system": "Система",
|
||||||
"users": "Пользователи",
|
"users": "Пользователи",
|
||||||
"audit": "Журнал",
|
"audit": "Журнал",
|
||||||
"library": "Библиотека",
|
"library": "Библиотека",
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in new issue
Block a user