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>
494 lines
17 KiB
TypeScript
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}`);
|
|
}
|
|
}
|