goon/mobile/src/api.ts
goon-foss 8306b8e997 feat(mobile): send X-Device-Id, one-time adopt-legacy
GoonClient attaches a stable per-install device id (SecureStore, lazy UUID) on
every request so server-side user state is scoped per device. On first launch
after update, call /me/adopt-legacy once (SecureStore flag) to claim the previous
shared state onto this device — the instance owner should relaunch first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 08:58:02 +02:00

521 lines
18 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,
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<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' });
}
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.
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<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 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()}`);
}
}
async listTags(params: {
q?: string;
order?: 'popular' | 'name';
page?: number;
per_page?: number;
for_movies?: boolean;
only_with_content?: boolean;
} = {}): 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');
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 } = {}): 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));
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;
}> {
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');
}
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 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 }),
});
}
async listFavorites(): Promise<FavoriteListOut> {
return this.request('/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(): Promise<FavoriteStudioListOut> {
return this.request('/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' });
}
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): 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<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}`);
}
}