// Cienki klient REST do backendu goon. Używa fetch + headerów X-API-Key. import { getAppSignatureHash } from './native/antiTamper'; import type { BlacklistKind, BlacklistOut, FavoriteAddOut, FavoriteListOut, FavoriteMovieAddOut, FavoriteMovieListOut, FavoriteStudioAddOut, FavoriteStudioListOut, KeepSide, MergeCandidateDetail, MergeCandidateListOut, MovieListOut, MovieOut, MoviesListParams, PerformerListOut, ProgressOut, ResolveAction, ResolveOut, ResolveResult, ScenesListParams, SceneListOut, SceneOut, SourceListOut, StudioListOut, TagListOut, WatchListOut, } from './types'; export class ApiError extends Error { constructor(public status: number, message: string) { super(message); this.name = 'ApiError'; } } export class GoonClient { constructor(private 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> { const sig = await getAppSignatureHash(); return { 'X-API-Key': this.apiKey, ...(sig ? { 'X-App-Signature': sig } : {}), }; } private async request(path: string, init?: RequestInit): Promise { 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}`); } 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(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 = {}; 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 { 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)); if (params.has_animated_thumbnail !== undefined) qs.set('has_animated_thumbnail', String(params.has_animated_thumbnail)); // 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. 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.origin) qs.set('origin', params.origin); 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(`/scenes?${qs.toString()}`); } async listMovies(params: MoviesListParams = {}): Promise { 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 { return this.request(`/movies/${movieId}`); } async resolveMoviePlayback(movieId: string, playbackId: string): Promise { return this.request(`/movies/${movieId}/playback/${playbackId}/resolve`, { method: 'POST', }); } async addSceneFavorite(sceneId: string): Promise { await this.request(`/scene-favorites/${sceneId}`, { method: 'POST' }); } async removeSceneFavorite(sceneId: string): Promise { 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()}`); } } async listTags(params: { q?: string; order?: 'popular' | 'name'; page?: number; per_page?: number; for_movies?: boolean; only_with_content?: boolean; } = {}): Promise { 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'); return this.request(`/tags?${qs.toString()}`); } async listPerformers(params: { q?: string; order?: 'scene_count' | 'name'; page?: number; per_page?: number } = {}): Promise { 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)); return this.request(`/performers?${qs.toString()}`); } async refreshPerformer(performerId: string, pages = 2): Promise<{ performer_id: string; canonical_name: string; counters: Record>; new_scenes: number; last_searched_at: string | null; }> { 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 { await this.request(`/scenes/${sceneId}/playback/${playbackId}/mark-dead`, { method: 'POST', }); } async markMoviePlaybackDead(movieId: string, playbackId: string): Promise { await this.request(`/movies/${movieId}/playback/${playbackId}/mark-dead`, { method: 'POST', }); } async listSources(): Promise { return this.request('/sources'); } async listStudios(params: { q?: string; order?: 'name' | 'scene_count'; page?: number; per_page?: number; for_movies?: boolean; only_with_content?: boolean; } = {}): Promise { 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 { return this.request(`/scenes/${id}`); } async resolvePlayback(sceneId: string, playbackId: string): Promise { return this.request(`/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 { 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 { return this.request(`/admin/merge-candidates/${id}`); } async resolveMergeCandidate( id: string, body: { action: ResolveAction; keep?: KeepSide; resolved_by?: string } ): Promise { return this.request(`/admin/merge-candidates/${id}/resolve`, { method: 'POST', body: JSON.stringify({ keep: 'left', resolved_by: 'mobile', ...body }), }); } async listFavorites(): Promise { return this.request('/favorites'); } async addFavorite(performerId: string): Promise { return this.request(`/favorites/${performerId}`, { method: 'POST' }); } async removeFavorite(performerId: string): Promise { 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 { await this.request(`/favorites/${performerId}/seen`, { method: 'POST' }); } async listFavoriteStudios(): Promise { return this.request('/favorites/studios'); } async addFavoriteStudio(studioId: string): Promise { return this.request(`/favorites/studios/${studioId}`, { method: 'POST' }); } async removeFavoriteStudio(studioId: string): Promise { 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 { await this.request(`/favorites/studios/${studioId}/seen`, { method: 'POST' }); } async listFavoriteMovies(): Promise { return this.request('/favorites/movies'); } async addFavoriteMovie(movieId: string): Promise { return this.request(`/favorites/movies/${movieId}`, { method: 'POST' }); } async removeFavoriteMovie(movieId: string): Promise { 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 { await this.request(`/favorites/movies/${movieId}/seen`, { method: 'POST' }); } async removeTagFromScene(sceneId: string, tagId: string): Promise { 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 { 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): Promise<{ scene_id: string; thumbnail_url: string | null; tube_used: string | null; sources_updated: number; }> { return this.request(`/scenes/${sceneId}/enrich-thumbnail`, { 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 getServerVersion(): Promise<{ version: string; 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 { 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 { 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 { 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 { return this.request(`/movies/${movieId}/progress`, { method: 'POST', body: JSON.stringify(body), }); } async listRecentWatch(limit = 10): Promise { return this.request(`/watch/recent?limit=${limit}`); } }