goon/mobile/src/api.ts
goon-foss 7ed878ce16 feat(mobile): gorne belki w jednym wierszu, pasek w Edge chowa sie przy przewijaniu
- SegmentTabs w ui.tsx: zakladki w wierszu narzedzi obok ikon
- Ulubione, performerka, studio, Performers i Tags: jeden wiersz pod naglowkiem
  zamiast trzech-czterech (etykieta z licznikiem, zakladki, podpowiedz, sortowanie)
- gwiazdka na ekranie performerki i studia bierze /favorites?counts=false
- Edge: kolejny klip chowa pasek, cofniecie pokazuje; podpis, przyciski i postep
  jada razem z paskiem; FAB zgloszen ukryty, lezal na "Not interested"
- changelog 2026-09-15c/d

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 15:37:53 +02:00

698 lines
26 KiB
TypeScript

// Cienki klient REST do backendu goon. Używa fetch + headerów X-API-Key.
import { getAppSignatureHash } from './native/antiTamper';
import { getDeviceId } from './storage';
import type {
BlacklistKind,
BlacklistOut,
FavoriteAddOut,
FavoriteListOut,
FavoriteMovieAddOut,
FavoriteMovieListOut,
FavoriteSiteAddOut,
FavoriteSiteListOut,
FavoriteStudioAddOut,
FavoriteStudioListOut,
KeepSide,
MergeCandidateDetail,
MergeCandidateListOut,
MovieListOut,
MovieOut,
MoviesListParams,
PerformerListOut,
ProgressOut,
ResolveAction,
ResolveOut,
ResolveResult,
SavedSearchOut,
ScenesListParams,
SceneListOut,
SceneOut,
SourceListOut,
StudioListOut,
TagListOut,
WatchListOut,
ClipAffinityOut,
ClipEventIn,
ClipFeedPage,
ClipListOut,
ClipOnboardingOptions,
} from './types';
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
this.name = 'ApiError';
}
}
export class GoonClient {
constructor(readonly baseUrl: string, private apiKey: string) {
this.baseUrl = baseUrl.replace(/\/+$/, '');
}
// Auth headers shared between request() i raw fetch wrappers (DELETE/204
// responses). Każdy hit do backendu MUSI zawierać X-App-Signature inaczej
// backend zwraca 403 (anti-tamper sig check, patrz auth.py).
private async _authHeaders(): Promise<Record<string, string>> {
const sig = await getAppSignatureHash();
const deviceId = await getDeviceId();
return {
'X-API-Key': this.apiKey,
'X-Device-Id': deviceId,
...(sig ? { 'X-App-Signature': sig } : {}),
};
}
// Jednorazowe przejęcie legacy stanu (sprzed device-scopingu) na to urządzenie.
async adoptLegacy(): Promise<{ device_id: string; moved: Record<string, number> }> {
return this.request('/me/adopt-legacy', { method: 'POST' });
}
// Backup/transfer: skopiuj stan (ulubione/blacklisty/progres/zapisane) z innego
// urządzenia (jego device_id = kod transferu) na to. Nie rusza starego telefonu.
async importFromDevice(
sourceCode: string,
): Promise<{ device_id: string; imported: Record<string, number> }> {
return this.request(`/me/import/${encodeURIComponent(sourceCode)}`, { method: 'POST' });
}
private async request<T>(path: string, init?: RequestInit): Promise<T> {
const auth = await this._authHeaders();
const res = await fetch(`${this.baseUrl}${path}`, {
...init,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
...auth,
...(init?.headers || {}),
},
});
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new ApiError(res.status, `${res.status}: ${text}`);
}
// 204 No Content (np. mark-dead endpoints), puste body, `res.json()` rzuciłby
// "JSON Parse error". To powodowało fałszywy "Failed" alert przy "Mark as invalid"
// mimo że backend POPRAWNIE oznaczał link dead (bug-report 2026-05-28 Voe + 06-03).
if (res.status === 204) {
return undefined as T;
}
const data = (await res.json()) as T;
// Backend zwraca path-only `/proxy/...` dla CDN-ów wymagających Referera
// (thumbnails, stream URLs). Klient prefixuje baseUrl-em rekursywnie tak żeby
// konsumenci dostali gotowe absolute URL-e bez per-screen logiki.
return this._absolutizeProxyUrls(data);
}
private _absolutizeProxyUrls<T>(data: T): T {
if (data === null || data === undefined) return data;
if (typeof data === 'string') {
return (data.startsWith('/proxy/') ? `${this.baseUrl}${data}` : data) as unknown as T;
}
if (Array.isArray(data)) {
return data.map((x) => this._absolutizeProxyUrls(x)) as unknown as T;
}
if (typeof data === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(data)) {
out[k] = this._absolutizeProxyUrls(v);
}
return out as T;
}
return data;
}
async healthz(): Promise<{ status: string }> {
return this.request('/healthz');
}
async listScenes(params: ScenesListParams = {}): Promise<SceneListOut> {
const qs = new URLSearchParams();
if (params.q) qs.set('q', params.q);
if (params.studio_slugs && params.studio_slugs.length) {
qs.set('studio_slugs', params.studio_slugs.join(','));
}
if (params.tags && params.tags.length) qs.set('tags', params.tags.join(','));
if (params.performer_ids && params.performer_ids.length) {
qs.set('performer_ids', params.performer_ids.join(','));
}
if (params.has_playback !== undefined) qs.set('has_playback', String(params.has_playback));
// Default: filtrujemy sceny <60s, bug-report 2026-05-23 (40cd28aa):
// "Takie sceny po 1 min to można wywalić". Pornapp/freshporno czasem
// zassuje teasery/trailery 30-50s, które są bezużyteczne na listach.
// Caller może override przez explicit 0 (lub null), np. admin browse.
//
// UWAGA, drugie dno: w SQL `duration_sec >= 60` jest NIEPRAWDĄ dla NULL, więc ten
// próg wycina też sceny o NIEZNANEJ długości, a nie tylko krótkie. Dla głównego
// feedu to akceptowalne, ale przy wejściu w KONKRETNY serwis/performera/tag ukrywał
// ogromne części katalogu i wyglądał jak zatrzymany ingest (zgłoszenia 2026-08-20:
// hentaimama 0 scen, porndish "zaprzestał ingestu"). Pomiar: 100% scen supjav,
// hentaigasm, hentaimama, hentaiplay, javflix i xanimeporn ma duration NULL, do tego
// xhamster 90%, porndish 89%, porntrex 77%. Ekrany drill-down przekazują więc 0.
const minDur = params.min_duration_sec ?? 60;
if (minDur > 0) qs.set('min_duration_sec', String(minDur));
if (params.max_duration_sec !== undefined)
qs.set('max_duration_sec', String(params.max_duration_sec));
if (params.released_within_days !== undefined)
qs.set('released_within_days', String(params.released_within_days));
if (params.min_quality_p !== undefined) qs.set('min_quality_p', String(params.min_quality_p));
if (params.include_stubs !== undefined) qs.set('include_stubs', String(params.include_stubs));
if (params.jav) qs.set('jav', 'true');
// Wertykał (animation/jav): backend odcina go od głównego feedu i odwrotnie.
if (params.vertical) qs.set('vertical', params.vertical);
if (params.origin) qs.set('origin', params.origin);
if (params.sort) qs.set('sort', params.sort);
if (params.seed) qs.set('seed', params.seed);
qs.set('page', String(params.page ?? 1));
qs.set('per_page', String(params.per_page ?? 50));
return this.request(`/scenes?${qs.toString()}`);
}
async listMovies(params: MoviesListParams = {}): Promise<MovieListOut> {
const qs = new URLSearchParams();
if (params.q) qs.set('q', params.q);
if (params.studio_slugs && params.studio_slugs.length) {
qs.set('studio_slugs', params.studio_slugs.join(','));
}
if (params.tags && params.tags.length) qs.set('tags', params.tags.join(','));
if (params.performer_ids && params.performer_ids.length) {
qs.set('performer_ids', params.performer_ids.join(','));
}
if (params.year_from !== undefined) qs.set('year_from', String(params.year_from));
if (params.year_to !== undefined) qs.set('year_to', String(params.year_to));
if (params.has_playback !== undefined) qs.set('has_playback', String(params.has_playback));
if (params.sort) qs.set('sort', params.sort);
qs.set('page', String(params.page ?? 1));
qs.set('per_page', String(params.per_page ?? 50));
return this.request(`/movies?${qs.toString()}`);
}
async getMovie(movieId: string): Promise<MovieOut> {
return this.request(`/movies/${movieId}`);
}
async resolveMoviePlayback(movieId: string, playbackId: string): Promise<ResolveOut> {
return this.request(`/movies/${movieId}/playback/${playbackId}/resolve`, {
method: 'POST',
});
}
async addSceneFavorite(sceneId: string): Promise<void> {
await this.request(`/scene-favorites/${sceneId}`, { method: 'POST' });
}
async listSceneFavorites(): Promise<{ items: SceneOut[]; total: number }> {
return this.request('/scene-favorites');
}
async removeSceneFavorite(sceneId: string): Promise<void> {
const res = await fetch(`${this.baseUrl}/scene-favorites/${sceneId}`, {
method: 'DELETE',
headers: await this._authHeaders(),
});
if (!res.ok && res.status !== 204) {
throw new ApiError(res.status, `${res.status}: ${await res.text()}`);
}
}
// Saved searches, zapisane słowa kluczowe (user-report mobilism).
async listSavedSearches(): Promise<SavedSearchOut[]> {
return this.request('/saved-searches');
}
async addSavedSearch(query: string): Promise<SavedSearchOut> {
return this.request('/saved-searches', {
method: 'POST',
body: JSON.stringify({ query }),
});
}
async removeSavedSearch(id: string): Promise<void> {
await this.request(`/saved-searches/${id}`, { method: 'DELETE' });
}
async listTags(params: {
q?: string;
order?: 'popular' | 'name';
page?: number;
per_page?: number;
for_movies?: boolean;
only_with_content?: boolean;
// Wertykał: lista tagów TEJ sekcji. Bez tego zakładka animacji dostawała
// zachodnie słownictwo, a główny feed 2664 tagi, których nie ma czym wypełnić.
vertical?: string;
} = {}): Promise<TagListOut> {
const qs = new URLSearchParams();
if (params.q) qs.set('q', params.q);
qs.set('order', params.order ?? 'popular');
qs.set('page', String(params.page ?? 1));
qs.set('per_page', String(params.per_page ?? 200));
if (params.for_movies) qs.set('for_movies', 'true');
if (params.only_with_content) qs.set('only_with_content', 'true');
if (params.vertical) qs.set('vertical', params.vertical);
return this.request(`/tags?${qs.toString()}`);
}
// Top tagi/kategorie performera (PerformerScenes chips). Agregat ze scen z żywym playbackiem.
async performerTags(performerId: string, limit = 20): Promise<TagListOut> {
return this.request(`/performers/${performerId}/tags?limit=${limit}`);
}
async listPerformers(params: { q?: string; order?: 'scene_count' | 'name'; page?: number; per_page?: number; vertical?: string } = {}): Promise<PerformerListOut> {
const qs = new URLSearchParams();
if (params.q) qs.set('q', params.q);
qs.set('order', params.order ?? 'scene_count');
qs.set('page', String(params.page ?? 1));
qs.set('per_page', String(params.per_page ?? 100));
// Bez tego zakladka JAV pokazywala zachodnia obsade: `performers.scene_count` liczy
// wylacznie glowny feed, wiec 1392 nazwiska JAV mialy tam 0 i szly na koniec listy.
if (params.vertical) qs.set('vertical', params.vertical);
return this.request(`/performers?${qs.toString()}`);
}
async refreshPerformer(performerId: string, pages = 2): Promise<{
performer_id: string;
canonical_name: string;
counters: Record<string, Record<string, number>>;
new_scenes: number;
last_searched_at: string | null;
/** "started" = backend mieli w tle i sceny dopiszą się za chwilę (od 2026-08-26). */
status?: string;
}> {
return this.request(`/performers/${performerId}/refresh?pages=${pages}`, {
method: 'POST',
});
}
async rescrapePerformer(performerId: string): Promise<{
performer_id: string;
canonical_name: string;
scenes_total: number;
scenes_processed: number;
thumbs_added: number;
tags_added: number;
failures: number;
capped: boolean;
cap_reason: string | null;
}> {
return this.request(`/performers/${performerId}/rescrape`, { method: 'POST' });
}
async markPlaybackDead(sceneId: string, playbackId: string): Promise<void> {
await this.request(`/scenes/${sceneId}/playback/${playbackId}/mark-dead`, {
method: 'POST',
});
}
// Long-press tile akcje. hideScene → wszystkie playback dead (scena znika z list).
async hideScene(sceneId: string): Promise<{ scene_id: string; playback_marked_dead: number }> {
return this.request(`/scenes/${sceneId}/hide`, { method: 'POST' });
}
// Scal `dropId` w `keepId` (mark-duplicate flow: long-press dup → wybór oryginału).
async mergeDuplicateScene(keepId: string, dropId: string): Promise<{ keep_id: string; dropped_id: string }> {
return this.request(`/scenes/${keepId}/merge/${dropId}`, { method: 'POST' });
}
async markMoviePlaybackDead(movieId: string, playbackId: string): Promise<void> {
await this.request(`/movies/${movieId}/playback/${playbackId}/mark-dead`, {
method: 'POST',
});
}
async listSources(): Promise<SourceListOut> {
return this.request('/sources');
}
/** Telemetria odtwarzania (fire-and-forget), zasila health/szybkość w rankingu
* źródeł. Best-effort: błąd jest połykany, nie może psuć playbacku. */
async reportPlaybackEvent(ev: {
origin: string;
status: 'success' | 'error';
scene_id?: string;
error_kind?: string;
error_detail?: string;
ttff_ms?: number;
}): Promise<void> {
try {
await this.request('/playback-events', {
method: 'POST',
body: JSON.stringify(ev),
});
} catch {
// świadomie ignorujemy, telemetria nie jest krytyczna
}
}
async listStudios(params: {
q?: string;
order?: 'name' | 'scene_count';
page?: number;
per_page?: number;
for_movies?: boolean;
only_with_content?: boolean;
} = {}): Promise<StudioListOut> {
const qs = new URLSearchParams();
if (params.q) qs.set('q', params.q);
qs.set('order', params.order ?? 'name');
qs.set('page', String(params.page ?? 1));
qs.set('per_page', String(params.per_page ?? 200));
if (params.for_movies) qs.set('for_movies', 'true');
if (params.only_with_content) qs.set('only_with_content', 'true');
return this.request(`/studios?${qs.toString()}`);
}
async getScene(id: string): Promise<SceneOut> {
return this.request(`/scenes/${id}`);
}
async getSimilarScenes(id: string, limit = 12): Promise<SceneListOut> {
return this.request(`/scenes/${id}/similar?limit=${limit}`);
}
async resolvePlayback(sceneId: string, playbackId: string): Promise<ResolveOut> {
return this.request<ResolveOut>(`/scenes/${sceneId}/playback/${playbackId}/resolve`, {
method: 'POST',
});
// Note: `/proxy/...` URLs są absolutyzowane w `request()` rekursywnie.
}
// expose baseUrl for player headers etc.
get apiBaseUrl(): string {
return this.baseUrl;
}
async listMergeCandidates(params: { status?: string; page?: number; per_page?: number } = {}): Promise<MergeCandidateListOut> {
const qs = new URLSearchParams();
qs.set('status', params.status ?? 'pending');
qs.set('kind', 'scene');
qs.set('page', String(params.page ?? 1));
qs.set('per_page', String(params.per_page ?? 50));
return this.request(`/admin/merge-candidates?${qs.toString()}`);
}
async getMergeCandidate(id: string): Promise<MergeCandidateDetail> {
return this.request(`/admin/merge-candidates/${id}`);
}
async resolveMergeCandidate(
id: string,
body: { action: ResolveAction; keep?: KeepSide; resolved_by?: string }
): Promise<ResolveResult> {
return this.request(`/admin/merge-candidates/${id}/resolve`, {
method: 'POST',
body: JSON.stringify({ keep: 'left', resolved_by: 'mobile', ...body }),
});
}
/** `counts: false` = bez liczników +N (tylko stan gwiazdki, serwer odpowiada od razu). */
async listFavorites(opts: { counts?: boolean } = {}): Promise<FavoriteListOut> {
return this.request(opts.counts === false ? '/favorites?counts=false' : '/favorites');
}
async addFavorite(performerId: string): Promise<FavoriteAddOut> {
return this.request(`/favorites/${performerId}`, { method: 'POST' });
}
async removeFavorite(performerId: string): Promise<void> {
const res = await fetch(`${this.baseUrl}/favorites/${performerId}`, {
method: 'DELETE',
headers: await this._authHeaders(),
});
if (!res.ok && res.status !== 204) {
const text = await res.text().catch(() => res.statusText);
throw new ApiError(res.status, `${res.status}: ${text}`);
}
}
async markFavoriteSeen(performerId: string): Promise<void> {
await this.request(`/favorites/${performerId}/seen`, { method: 'POST' });
}
async listFavoriteStudios(opts: { counts?: boolean } = {}): Promise<FavoriteStudioListOut> {
return this.request(opts.counts === false ? '/favorites/studios?counts=false' : '/favorites/studios');
}
async addFavoriteStudio(studioId: string): Promise<FavoriteStudioAddOut> {
return this.request(`/favorites/studios/${studioId}`, { method: 'POST' });
}
async removeFavoriteStudio(studioId: string): Promise<void> {
const res = await fetch(`${this.baseUrl}/favorites/studios/${studioId}`, {
method: 'DELETE',
headers: await this._authHeaders(),
});
if (!res.ok && res.status !== 204) {
const text = await res.text().catch(() => res.statusText);
throw new ApiError(res.status, `${res.status}: ${text}`);
}
}
async markFavoriteStudioSeen(studioId: string): Promise<void> {
await this.request(`/favorites/studios/${studioId}/seen`, { method: 'POST' });
}
// Przypiete strony (zakladka Sites). Origin ma dwukropek (`tube:hqpornercom`), wiec
// idzie przez encodeURIComponent.
async listFavoriteSites(): Promise<FavoriteSiteListOut> {
return this.request('/favorites/sites');
}
async addFavoriteSite(origin: string): Promise<FavoriteSiteAddOut> {
return this.request(`/favorites/sites/${encodeURIComponent(origin)}`, { method: 'POST' });
}
async removeFavoriteSite(origin: string): Promise<void> {
const res = await fetch(`${this.baseUrl}/favorites/sites/${encodeURIComponent(origin)}`, {
method: 'DELETE',
headers: await this._authHeaders(),
});
if (!res.ok && res.status !== 204) {
const text = await res.text().catch(() => res.statusText);
throw new ApiError(res.status, `${res.status}: ${text}`);
}
}
async listFavoriteMovies(): Promise<FavoriteMovieListOut> {
return this.request('/favorites/movies');
}
async addFavoriteMovie(movieId: string): Promise<FavoriteMovieAddOut> {
return this.request(`/favorites/movies/${movieId}`, { method: 'POST' });
}
async removeFavoriteMovie(movieId: string): Promise<void> {
const res = await fetch(`${this.baseUrl}/favorites/movies/${movieId}`, {
method: 'DELETE',
headers: await this._authHeaders(),
});
if (!res.ok && res.status !== 204) {
const text = await res.text().catch(() => res.statusText);
throw new ApiError(res.status, `${res.status}: ${text}`);
}
}
async markFavoriteMovieSeen(movieId: string): Promise<void> {
await this.request(`/favorites/movies/${movieId}/seen`, { method: 'POST' });
}
async removeTagFromScene(sceneId: string, tagId: string): Promise<void> {
const res = await fetch(`${this.baseUrl}/scenes/${sceneId}/tags/${tagId}`, {
method: 'DELETE',
headers: await this._authHeaders(),
});
if (!res.ok && res.status !== 204) {
const text = await res.text().catch(() => res.statusText);
throw new ApiError(res.status, `${res.status}: ${text}`);
}
}
async removePerformerFromScene(sceneId: string, performerId: string): Promise<void> {
const res = await fetch(
`${this.baseUrl}/scenes/${sceneId}/performers/${performerId}`,
{ method: 'DELETE', headers: await this._authHeaders() },
);
if (!res.ok && res.status !== 204) {
const text = await res.text().catch(() => res.statusText);
throw new ApiError(res.status, `${res.status}: ${text}`);
}
}
async enrichSceneTags(sceneId: string): Promise<{
scene_id: string;
added: number;
tube_used: string | null;
tags: string[];
}> {
return this.request(`/scenes/${sceneId}/enrich-tags`, { method: 'POST' });
}
async enrichSceneDuration(sceneId: string): Promise<{
scene_id: string;
duration_sec: number | null;
tube_used: string | null;
}> {
return this.request(`/scenes/${sceneId}/enrich-duration`, { method: 'POST' });
}
async enrichSceneThumbnail(sceneId: string, force = false): Promise<{
scene_id: string;
thumbnail_url: string | null;
tube_used: string | null;
sources_updated: number;
}> {
const qs = force ? '?force=true' : '';
return this.request(`/scenes/${sceneId}/enrich-thumbnail${qs}`, { method: 'POST' });
}
async enrichSceneStudio(sceneId: string): Promise<{
scene_id: string;
studio_id: string | null;
studio_name: string | null;
tube_used: string | null;
}> {
return this.request(`/scenes/${sceneId}/enrich-studio`, { method: 'POST' });
}
async submitBugReport(payload: {
message: string;
screen_name?: string | null;
app_version?: string | null;
scene_id?: string | null;
screenshot_b64?: string | null;
}): Promise<{ id: string }> {
return this.request('/bug-reports', {
method: 'POST',
body: JSON.stringify(payload),
});
}
async listMyBugReports(): Promise<{
items: {
id: string;
created_at: string;
screen_name: string | null;
message: string;
response: string | null;
responded_at: string | null;
response_seen: boolean;
}[];
unseen: number;
}> {
return this.request('/bug-reports/mine');
}
async markBugRepliesSeen(): Promise<{ marked: number }> {
return this.request('/bug-reports/mine/seen', { method: 'POST' });
}
async getServerVersion(): Promise<{
version: string;
runtime_version?: string | null;
apk_url: string | null;
}> {
// /version nie wymaga API key (publiczny endpoint do upgrade discovery).
// Używamy this.request bo czysty fetch bez API key też jest OK, ale endpoint jest auth-free.
const res = await fetch(`${this.baseUrl}/version`);
if (!res.ok) {
throw new ApiError(res.status, `version: HTTP ${res.status}`);
}
return res.json();
}
// Blacklist
async listBlacklist(): Promise<BlacklistOut> {
return this.request('/blacklist');
}
async addBlacklist(kind: BlacklistKind, entityId: string): Promise<{ created: boolean }> {
return this.request(`/blacklist/${kind}/${entityId}`, { method: 'POST' });
}
async removeBlacklist(kind: BlacklistKind, entityId: string): Promise<void> {
const res = await fetch(`${this.baseUrl}/blacklist/${kind}/${entityId}`, {
method: 'DELETE',
headers: await this._authHeaders(),
});
if (!res.ok && res.status !== 204) {
const text = await res.text().catch(() => res.statusText);
throw new ApiError(res.status, `${res.status}: ${text}`);
}
}
// Watch history
async upsertProgress(
sceneId: string,
body: { position_sec: number; duration_sec?: number; finished?: boolean },
): Promise<ProgressOut> {
return this.request(`/scenes/${sceneId}/progress`, {
method: 'POST',
body: JSON.stringify(body),
});
}
// Movie progress, mirror upsertProgress dla movies. Backend dodał 2026-05-28.
async upsertMovieProgress(
movieId: string,
body: { position_sec: number; duration_sec?: number; finished?: boolean },
): Promise<ProgressOut> {
return this.request(`/movies/${movieId}/progress`, {
method: 'POST',
body: JSON.stringify(body),
});
}
async listRecentWatch(limit = 10): Promise<WatchListOut> {
return this.request(`/watch/recent?limit=${limit}`);
}
// ---- Swipe (klipy) ------------------------------------------------------------------
// Feed: kursor zamiast offsetu (seed + pozycja po kluczu glownym). Pierwsza strona bez
// kursora dostaje nowy seed z serwera; kolejne ida po `next_cursor`. Kazdy podany klip
// serwer od razu pamieta jako widziany, wiec nie wraca.
async getClipFeed(params: { cursor?: string | null; seed?: string | null; limit?: number } = {}): Promise<ClipFeedPage> {
const q = new URLSearchParams();
if (params.cursor) q.set('cursor', params.cursor);
else if (params.seed) q.set('seed', params.seed);
q.set('limit', String(params.limit ?? 10));
return this.request(`/clips/feed?${q.toString()}`);
}
// Paczka zdarzen (co ~5 s / 10 zdarzen / przy zejsciu w tlo). 204, bledy polykamy
// u wolajacego: telemetria nie moze psuc ogladania.
async postClipEvents(sessionId: string, events: ClipEventIn[]): Promise<void> {
await this.request<void>('/clips/events', {
method: 'POST',
body: JSON.stringify({ session_id: sessionId, events }),
});
}
async getClipOnboardingOptions(): Promise<ClipOnboardingOptions> {
return this.request('/clips/onboarding-options');
}
async postClipOnboarding(body: { tags: string[]; niches: string[] }): Promise<ClipAffinityOut> {
return this.request('/clips/onboarding', { method: 'POST', body: JSON.stringify(body) });
}
async getClipAffinity(): Promise<ClipAffinityOut> {
return this.request('/clips/affinity');
}
// „Resetuj moj feed": wagi; z `seen` takze pamiec widzianych (feed od zera).
async resetClipAffinity(seen = false): Promise<void> {
await this.request<void>(`/clips/affinity?seen=${seen ? 'true' : 'false'}`, { method: 'DELETE' });
}
async listClipFavorites(limit = 50, offset = 0): Promise<ClipListOut> {
return this.request(`/clips/favorites?limit=${limit}&offset=${offset}`);
}
async addClipBlacklist(kind: 'tag' | 'niche' | 'creator', value: string): Promise<void> {
await this.request<void>(`/clips/blacklist/${kind}/${encodeURIComponent(value)}`, { method: 'POST' });
}
async removeClipBlacklist(kind: 'tag' | 'niche' | 'creator', value: string): Promise<void> {
await this.request<void>(`/clips/blacklist/${kind}/${encodeURIComponent(value)}`, { method: 'DELETE' });
}
}