Likes and dislikes without a network are kept in a pending_reactions table (last reaction per track wins) and sent when the connection returns, instead of being rolled back. Downloaded track DTOs pick up the new reaction so the offline wave and Downloads reflect it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
121 lines
4.6 KiB
TypeScript
121 lines
4.6 KiB
TypeScript
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 без ответа сервера
|
|
}
|