goon/mobile/src/api.ts
jtrzupek 6eb7cdd320 feat(movies): watched/continue-watching tracking end-to-end
Bug-report b207ff17 2026-05-26 ("przydaloby sie oznaczenie filmow juz
obejrzanych" - sceny mialy watched badge + dim, filmom brakowalo).

Backend:
- alembic 0018_movie_play_progress: nowa tabela (mirror scene_play_progress)
- MoviePlayProgress SQLAlchemy model
- MovieOut schema dolane finished/position_sec/last_played_at
- POST+DELETE /movies/{id}/progress endpointy (upsert via pg ON CONFLICT)
- _movie_to_out wstrzykuje progress z DB

Mobile:
- RouteParams.entityKind: 'scene'|'movie' (default scene dla back-compat)
- PlayerScreen NativeVideoPlayer + EmbedWebViewPlayer dispatchuja
  upsertProgress vs upsertMovieProgress po entityKind
- MovieDetailScreen przekazuje entityKind='movie' do nav
- MoviePosterCard renderuje dim + check badge + progress bar
  (parity ze ScenesScreen pattern)

Wczesniej MovieDetail przekazywal movieId jako sceneId -> backend
/scenes/<movieId>/progress zwracal 404 (silently caught). Po dodaniu
dedykowanego movie endpoint proper routing dziala.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:24:06 +02:00

494 lines
17 KiB
TypeScript

// 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<Record<string, string>> {
const sig = await getAppSignatureHash();
return {
'X-API-Key': this.apiKey,
...(sig ? { 'X-App-Signature': sig } : {}),
};
}
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}`);
}
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));
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<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()}`);
}
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',
});
}
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}`);
}
}