refactor: isolate playback and upload use cases
ci / module boundaries / boundaries (push) Successful in 6s
ci / api / api (push) Successful in 47s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / db / db (push) Successful in 1m4s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m30s
ci / ts / ts (packages + web) (push) Successful in 2m54s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m14s

This commit is contained in:
mrleo1nid committed 2026-10-22 13:52:16 +03:00
1 parent c84f90e2cc
commit e158558da3
56 files changed
+3337 -2196

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
# Общие правила клиентов
- Очередь/учёт событий: `src/player.ts`; волна: `src/wave.ts`; сессия: `src/auth.ts`.
- Не импортируй web, React Native или системные media API. Платформы передают IO через узкие порты.
- Поздние ответы старой wave-сессии не меняют новую; feedback после ошибки возвращается только в ту же сессию.
- `pnpm --filter @pipu/core test`, `pnpm --filter @pipu/core typecheck`; после изменения экспортов проверь web и mobile.
+2
View File
@@ -25,3 +25,5 @@ export { normalizeGain, TARGET_LUFS } from './gain';
export { trackShareUrl } from './share';
export { checkServer, normalizeServerUrl, type ServerCheck } from './server';
export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, waveScale, type Rgb, type WaveState } from './wave-anim';
export { WaveCoordinator, EARLY_SKIP_MS, WAVE_REFILL_AT, type WaveInfo, type WaveOutcome, type WaveFeedback, type WaveGateway } from './wave';
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it, vi } from 'vitest';
import { WaveCoordinator, type WaveFeedback } from './wave';
function deferred<T>() {
let resolve!: (v: T) => void;
let reject!: (e: Error) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
describe('wave coordination', () => {
it('preserves unsent feedback when changing context in the same session', async () => {
const next = vi.fn().mockResolvedValue(1);
const wave = new WaveCoordinator<number>({ next });
wave.activate('a');
wave.note('track', 'like');
wave.changeContext('a');
await wave.refill(0, true, () => {});
expect(next).toHaveBeenCalledWith('a', [{ track_id: 'track', outcome: 'like' }]);
});
it('ignores late results and allows the new session to refill immediately', async () => {
const old = deferred<number>();
const next = vi.fn().mockReturnValueOnce(old.promise).mockResolvedValueOnce(2);
const wave = new WaveCoordinator<number>({ next });
const apply = vi.fn();
wave.activate('old');
const first = wave.refill(0, false, apply);
wave.activate('new');
await wave.refill(0, false, apply);
old.resolve(1);
await first;
expect(apply.mock.calls.map(([value]) => value)).toEqual([2]);
});
it('retries feedback in order and respects threshold, refresh and single in-flight request', async () => {
const request = deferred<number>();
const next = vi.fn().mockReturnValueOnce(request.promise).mockResolvedValueOnce(2);
const wave = new WaveCoordinator<number>({ next });
const apply = vi.fn();
wave.activate('a');
wave.note('one', 'skip_early');
await wave.refill(3, false, apply);
expect(next).not.toHaveBeenCalled();
const first = wave.refill(3, true, apply);
wave.note('two', 'like');
await wave.refill(0, false, apply);
expect(next).toHaveBeenCalledTimes(1);
request.reject(new Error('offline'));
await first;
await wave.refill(2, false, apply);
expect(next.mock.calls[1]).toEqual(['a', [
{ track_id: 'one', outcome: 'skip_early' }, { track_id: 'two', outcome: 'like' },
] satisfies WaveFeedback[]]);
expect(apply).toHaveBeenCalledTimes(1);
});
it('does not restore old feedback or clear a newer in-flight request after a stale failure', async () => {
const old = deferred<number>();
const fresh = deferred<number>();
const next = vi.fn().mockReturnValueOnce(old.promise).mockReturnValueOnce(fresh.promise);
const wave = new WaveCoordinator<number>({ next });
wave.activate('a');
wave.note('old', 'dislike');
const first = wave.refill(0, false, () => {});
wave.activate('a'); // same session, new context
wave.note('new', 'like');
const second = wave.refill(0, false, () => {});
old.reject(new Error('late failure'));
await first;
await wave.refill(0, false, () => {});
expect(next).toHaveBeenCalledTimes(2);
expect(next.mock.calls[1]?.[1]).toEqual([{ track_id: 'new', outcome: 'like' }]);
fresh.resolve(2);
await second;
});
});
+62
View File
@@ -0,0 +1,62 @@
/** Policy shared by web and native players; queue ownership stays with the adapter. */
export const EARLY_SKIP_MS = 30_000;
export const WAVE_REFILL_AT = 2;
export type WaveOutcome = 'complete' | 'skip' | 'skip_early' | 'like' | 'dislike';
export interface WaveInfo {
sessionId: string;
activity: string | null;
mood: string | null;
fallback: boolean;
}
export interface WaveFeedback { track_id: string; outcome: WaveOutcome }
export interface WaveGateway<T> {
next(sessionId: string, feedback: WaveFeedback[]): Promise<T>;
}
export class WaveCoordinator<T> {
private sessionId: string | null = null;
private generation = 0;
private pending: WaveFeedback[] = [];
private loading: number | null = null;
constructor(private readonly gateway: WaveGateway<T>) {}
get version() { return this.generation; }
isCurrent(version: number) { return version === this.generation; }
/** Also invalidates in-flight responses when the context changes within a session. */
activate(sessionId: string | null) {
this.sessionId = sessionId;
this.pending = [];
this.loading = null;
return ++this.generation;
}
changeContext(sessionId: string) {
const pending = this.sessionId === sessionId ? this.pending : [];
const version = this.activate(sessionId);
this.pending = pending;
return version;
}
note(trackId: string, outcome: WaveOutcome) {
if (this.sessionId) this.pending.push({ track_id: trackId, outcome });
}
async refill(remaining: number, refresh: boolean, apply: (result: T, current: () => boolean) => void | Promise<void>) {
const id = this.sessionId;
const version = this.generation;
if (!id || this.loading === version || (!refresh && remaining > WAVE_REFILL_AT)) return;
this.loading = version;
const feedback = this.pending;
this.pending = [];
try {
const result = await this.gateway.next(id, feedback);
if (this.isCurrent(version)) await apply(result, () => this.isCurrent(version));
} catch {
if (this.isCurrent(version)) this.pending = [...feedback, ...this.pending];
} finally {
if (this.loading === version) this.loading = null;
}
}
}