import { ApiError, unwrap, type AuthSession, type PlayEvent } from '@pipu/core'; import { shortHash } from '../lib/storage'; import { getDb } from './db'; const BATCH = 500; type Reaction = 'like' | 'dislike' | null; /** * Отправка событий прослушивания и реакций с запасом на офлайн: что не ушло из-за сети — в SQLite, * досылается при появлении сети (`POST /events` идемпотентен по id события, реакция — PUT/DELETE, * для трека хранится только последняя). */ export class PersistentEvents { private readonly owner: string; private draining = false; constructor( private readonly session: AuthSession, server: string, userId: string, ) { this.owner = shortHash(`${server}|${userId}`); } private async post(events: PlayEvent[]) { unwrap(await this.session.client.POST('/api/v1/events', { body: { events } })); } /** Sink для EventBuffer: при сетевой ошибке сохраняет пачку и «успешно» завершается. */ send = async (events: PlayEvent[]) => { try { await this.post(events); void this.drain(); } catch (e) { if (!isNetwork(e)) throw e; const db = await getDb(); await db.runAsync('INSERT INTO pending_events (owner, body) VALUES (?, ?)', this.owner, JSON.stringify(events)); } }; private async putReaction(trackId: string, reaction: Reaction) { const params = { params: { path: { id: trackId } } }; if (reaction) unwrap(await this.session.client.PUT('/api/v1/me/reactions/tracks/{id}', { ...params, body: { reaction } })); else unwrap(await this.session.client.DELETE('/api/v1/me/reactions/tracks/{id}', params)); } /** Реакция на трек. Без сети — откладывается и возвращает `queued`; ошибку сервера пробрасывает. */ async react(trackId: string, reaction: Reaction): Promise<'sent' | 'queued'> { const db = await getDb(); try { await this.putReaction(trackId, reaction); } catch (e) { if (!isNetwork(e)) throw e; await db.runAsync( 'INSERT OR REPLACE INTO pending_reactions (owner, track_id, reaction) VALUES (?, ?, ?)', this.owner, trackId, reaction, ); return 'queued'; } // Более свежая реакция ушла — отложенная устарела. await db.runAsync('DELETE FROM pending_reactions WHERE owner = ? AND track_id = ?', this.owner, trackId); void this.drain(); return 'sent'; } /** Досылает отложенные реакции и пачки событий; останавливается на первой сетевой ошибке. */ async drain() { if (this.draining) return; this.draining = true; try { const db = await getDb(); const reactions = await db.getAllAsync<{ track_id: string; reaction: Reaction }>( 'SELECT track_id, reaction FROM pending_reactions WHERE owner = ?', this.owner, ); for (const r of reactions) { try { await this.putReaction(r.track_id, r.reaction); } catch (e) { if (isNetwork(e)) return; // трек удалён из библиотеки и т. п. — отбрасываем } await db.runAsync( 'DELETE FROM pending_reactions WHERE owner = ? AND track_id = ? AND reaction IS ?', this.owner, r.track_id, r.reaction, ); } for (;;) { const rows = await db.getAllAsync<{ id: number; body: string }>( 'SELECT id, body FROM pending_events WHERE owner = ? ORDER BY id LIMIT 20', this.owner, ); if (!rows.length) return; for (const r of rows) { const events = JSON.parse(r.body) as PlayEvent[]; try { for (let i = 0; i < events.length; i += BATCH) await this.post(events.slice(i, i + BATCH)); } catch (e) { if (isNetwork(e)) return; // Сервер отверг пачку (4xx) — повтор не поможет, отбрасываем. } await db.runAsync('DELETE FROM pending_events WHERE id = ?', r.id); } } } finally { this.draining = false; } } } function isNetwork(e: unknown): boolean { if (e instanceof ApiError) return e.code === 'common.network' || e.status >= 500; return true; // fetch бросает TypeError без ответа сервера }