From 0a1f9832b3c0072d1e40a92d4c40b5b40bce218f Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Tue, 11 Aug 2026 11:02:48 +0530 Subject: [PATCH 1/4] feat(backend): add Simkl proxy endpoints for Netlify and Supabase --- .../netlify/functions/_backend.js | 74 +++++++++ .../netlify/functions/simkl-proxy.js | 3 + supabase/functions/simkl-proxy/index.ts | 151 ++++++++++++++++++ 3 files changed, 228 insertions(+) create mode 100644 netlify-auth-site/netlify/functions/simkl-proxy.js create mode 100644 supabase/functions/simkl-proxy/index.ts diff --git a/netlify-auth-site/netlify/functions/_backend.js b/netlify-auth-site/netlify/functions/_backend.js index 030a113ac..c4989acfc 100644 --- a/netlify-auth-site/netlify/functions/_backend.js +++ b/netlify-auth-site/netlify/functions/_backend.js @@ -1386,6 +1386,79 @@ async function handleTraktProxy(event) { } } +const SIMKL_ALLOWED_PATHS = [ + "/oauth/pin", + "/oauth/token", + "/scrobble/", + "/sync/" +]; + +async function handleSimklProxy(event) { + const preflight = options(event); + if (preflight) return preflight; + try { + assertAppRequest(event); + const pathParam = event.queryStringParameters?.path || ""; + const method = String(event.queryStringParameters?.method || "GET").toUpperCase(); + if (!pathParam) return json(400, { error: "Missing path parameter" }); + if (!SIMKL_ALLOWED_PATHS.some((allowed) => pathParam.startsWith(allowed))) { + return json(403, { error: "Path not allowed" }); + } + const clientId = process.env.SIMKL_CLIENT_ID || ""; + const clientSecret = process.env.SIMKL_CLIENT_SECRET || ""; + if (!clientId) throw new Error("Simkl credentials not configured"); + const simklUrl = new URL(`https://api.simkl.com${pathParam}`); + Object.entries(event.queryStringParameters || {}).forEach(([key, value]) => { + if (key !== "path" && key !== "method" && value !== undefined && value !== null) { + simklUrl.searchParams.set(key, String(value)); + } + }); + + let requestBody = undefined; + if (method === "POST" || method === "DELETE") { + let body = {}; + try { + body = event.body + ? JSON.parse(event.isBase64Encoded ? Buffer.from(event.body, "base64").toString("utf8") : event.body) + : {}; + } catch { + body = {}; + } + if (pathParam.includes("/oauth/token")) { + body.client_id = clientId; + if (clientSecret) body.client_secret = clientSecret; + } + requestBody = Object.keys(body).length > 0 ? JSON.stringify(body) : undefined; + } + + const headers = { + "content-type": "application/json", + "simkl-api-key": clientId + }; + const userToken = event.headers["x-user-token"] || event.headers["X-User-Token"]; + if (userToken) headers.authorization = `Bearer ${userToken}`; + + const response = await fetch(simklUrl, { method, headers, body: requestBody }); + const text = await response.text(); + let data; + try { + data = text ? JSON.parse(text) : { status: response.status }; + } catch { + data = text ? { raw: text } : { status: response.status }; + } + return { + statusCode: response.status, + headers: { + ...JSON_HEADERS, + "cache-control": "no-store" + }, + body: JSON.stringify(data) + }; + } catch (error) { + return json(502, { error: errorMessage(error) }); + } +} + function payloadMetrics(payload) { const root = typeof payload === "string" ? JSON.parse(payload) : payload; const profiles = Array.isArray(root.profiles) ? root.profiles : null; @@ -2314,6 +2387,7 @@ module.exports = { handleCloudAuthReset, handleTmdbProxy, handleTraktProxy, + handleSimklProxy, handleTvAuthApprove, handleTvAuthComplete, handleTvAuthStart, diff --git a/netlify-auth-site/netlify/functions/simkl-proxy.js b/netlify-auth-site/netlify/functions/simkl-proxy.js new file mode 100644 index 000000000..a7dece5b2 --- /dev/null +++ b/netlify-auth-site/netlify/functions/simkl-proxy.js @@ -0,0 +1,3 @@ +const { handleSimklProxy } = require("./_backend"); + +exports.handler = handleSimklProxy; diff --git a/supabase/functions/simkl-proxy/index.ts b/supabase/functions/simkl-proxy/index.ts new file mode 100644 index 000000000..ff9c3e0e5 --- /dev/null +++ b/supabase/functions/simkl-proxy/index.ts @@ -0,0 +1,151 @@ +// Simkl API Proxy - Secured with rate limiting and path allowlist +// Deploy with: npx supabase functions deploy simkl-proxy +// Set secrets: +// npx supabase secrets set SIMKL_CLIENT_ID=your_id +// npx supabase secrets set SIMKL_CLIENT_SECRET=your_secret +// npx supabase secrets set APP_ANON_KEY=your_anon_key + +import { serve } from "https://deno.land/std@0.168.0/http/server.ts" + +const SIMKL_BASE_URL = "https://api.simkl.com" + +const RATE_LIMIT = 100 +const RATE_WINDOW_MS = 60 * 1000 +const rateLimitMap = new Map() + +const ALLOWED_PATHS = [ + '/oauth/pin', + '/oauth/token', + '/scrobble/', + '/sync/' +] + +function isPathAllowed(path: string): boolean { + return ALLOWED_PATHS.some(allowed => path.startsWith(allowed)) +} + +function getClientIP(req: Request): string { + return req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || + req.headers.get('x-real-ip') || + req.headers.get('cf-connecting-ip') || + 'unknown' +} + +function checkRateLimit(ip: string): { allowed: boolean; remaining: number; resetIn: number } { + const now = Date.now() + const record = rateLimitMap.get(ip) + + if (!record || now > record.resetTime) { + rateLimitMap.set(ip, { count: 1, resetTime: now + RATE_WINDOW_MS }) + return { allowed: true, remaining: RATE_LIMIT - 1, resetIn: RATE_WINDOW_MS } + } + + if (record.count >= RATE_LIMIT) { + return { allowed: false, remaining: 0, resetIn: record.resetTime - now } + } + + record.count++ + return { allowed: true, remaining: RATE_LIMIT - record.count, resetIn: record.resetTime - now } +} + +const DEFAULT_ALLOWED_ORIGINS = (Deno.env.get('CORS_ALLOWED_ORIGINS') || 'https://auth.arvio.tv,https://arvio.tv').split(',').map(s => s.trim()).filter(Boolean) + +function corsHeaders(req: Request) { + const origin = req.headers.get('origin') || '' + const allowed = DEFAULT_ALLOWED_ORIGINS + const allowOrigin = allowed.includes(origin) ? origin : 'null' + return { + 'Access-Control-Allow-Origin': allowOrigin, + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type, x-user-token', + } +} + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders(req) }) + } + + try { + const clientIP = getClientIP(req) + const rateCheck = checkRateLimit(clientIP) + if (!rateCheck.allowed) { + return new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { + headers: { ...corsHeaders(req), 'Content-Type': 'application/json' }, + status: 429, + }) + } + + const SIMKL_CLIENT_ID = Deno.env.get('SIMKL_CLIENT_ID') + if (!SIMKL_CLIENT_ID) { + throw new Error('Simkl credentials not configured') + } + + const url = new URL(req.url) + const path = url.searchParams.get('path') + const method = url.searchParams.get('method') || 'GET' + + if (!path || !isPathAllowed(path)) { + return new Response(JSON.stringify({ error: 'Path not allowed' }), { + headers: { ...corsHeaders(req), 'Content-Type': 'application/json' }, + status: 403, + }) + } + + const simklUrl = new URL(`${SIMKL_BASE_URL}${path}`) + url.searchParams.forEach((value, key) => { + if (key !== 'path' && key !== 'method') { + simklUrl.searchParams.set(key, value) + } + }) + + const headers: Record = { + 'Content-Type': 'application/json', + 'simkl-api-key': SIMKL_CLIENT_ID, + } + + const userToken = req.headers.get('x-user-token') + if (userToken) { + headers['Authorization'] = `Bearer ${userToken}` + } + + let body: string | undefined + if (method === 'POST' || method === 'DELETE') { + let reqBody: Record = {} + try { + reqBody = await req.json() + } catch { + // empty body + } + if (path.includes('/oauth/token')) { + reqBody.client_id = SIMKL_CLIENT_ID + const SIMKL_CLIENT_SECRET = Deno.env.get('SIMKL_CLIENT_SECRET') + if (SIMKL_CLIENT_SECRET) reqBody.client_secret = SIMKL_CLIENT_SECRET + } + body = Object.keys(reqBody).length > 0 ? JSON.stringify(reqBody) : undefined + } + + const response = await fetch(simklUrl.toString(), { + method: method, + headers: headers, + body: body, + }) + + const responseText = await response.text() + let data + try { + data = responseText ? JSON.parse(responseText) : { status: response.status } + } catch { + data = responseText ? { raw: responseText } : { status: response.status } + } + + return new Response(JSON.stringify(data), { + headers: { ...corsHeaders(req), 'Content-Type': 'application/json' }, + status: response.status, + }) + } catch (error) { + return new Response(JSON.stringify({ error: (error as Error).message }), { + headers: { ...corsHeaders(req), 'Content-Type': 'application/json' }, + status: 500, + }) + } +}) From 7e930ce5e6faa558b53b4d21c6e1542abec5f24c Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Tue, 11 Aug 2026 11:02:57 +0530 Subject: [PATCH 2/4] feat(web): integrate Simkl tracking client and API proxy --- web/app/api/simkl/[...path]/route.ts | 73 ++++++++++++++++ web/lib/simkl.ts | 125 +++++++++++++++++++++++++++ web/lib/sync.ts | 15 ++-- 3 files changed, 207 insertions(+), 6 deletions(-) create mode 100644 web/app/api/simkl/[...path]/route.ts create mode 100644 web/lib/simkl.ts diff --git a/web/app/api/simkl/[...path]/route.ts b/web/app/api/simkl/[...path]/route.ts new file mode 100644 index 000000000..1ee20f6c7 --- /dev/null +++ b/web/app/api/simkl/[...path]/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; + +function envValue(value: string | undefined, fallback = "") { + return value && !value.startsWith("$") ? value : fallback; +} + +async function handler(request: NextRequest, context: { params: Promise<{ path: string[] }> }) { + const { path } = await context.params; + const netlifyBackendUrl = ( + process.env.NEXT_PUBLIC_NETLIFY_BACKEND_URL ?? + process.env.NETLIFY_BACKEND_URL ?? + "https://auth.arvio.tv/.netlify/functions" + ).replace(/\/+$/, ""); + const appAnonKey = envValue(process.env.NEXT_PUBLIC_ARVIO_APP_ANON_KEY, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ""); + const simklClientId = process.env.NEXT_PUBLIC_SIMKL_CLIENT_ID ?? process.env.SIMKL_CLIENT_ID ?? ""; + const simklSecret = process.env.SIMKL_CLIENT_SECRET ?? ""; + const input = new URL(request.url); + const method = request.method; + const body = method === "GET" || method === "HEAD" ? undefined : await request.text(); + const normalizedPath = path.join("/"); + + let target: URL; + let headers: HeadersInit; + + const usesNetlifyProxy = netlifyBackendUrl.startsWith("https://") && appAnonKey.length > 40; + + if (usesNetlifyProxy) { + target = new URL(`${netlifyBackendUrl}/simkl-proxy`); + target.searchParams.set("path", `/${normalizedPath}`); + target.searchParams.set("method", method); + input.searchParams.forEach((value, key) => target.searchParams.set(key, value)); + headers = { + apikey: appAnonKey, + Authorization: `Bearer ${appAnonKey}` + }; + const userToken = request.headers.get("x-user-token"); + if (userToken) headers["x-user-token" as keyof HeadersInit] = userToken; + } else if (simklClientId) { + target = new URL(`https://api.simkl.com/${normalizedPath}`); + input.searchParams.forEach((value, key) => target.searchParams.set(key, value)); + headers = { + "content-type": "application/json", + "simkl-api-key": simklClientId + }; + const userToken = request.headers.get("x-user-token"); + if (userToken) headers.Authorization = `Bearer ${userToken}`; + } else { + return NextResponse.json({ error: "Simkl proxy is not configured" }, { status: 500 }); + } + + const parsedBody = body && normalizedPath === "oauth/token" && simklSecret && !usesNetlifyProxy + ? JSON.stringify({ ...JSON.parse(body), client_id: simklClientId, client_secret: simklSecret }) + : body; + + const response = await fetch(target, { + method, + headers, + body: parsedBody, + cache: "no-store" + }); + + const responseHeaders = new Headers(); + responseHeaders.set("content-type", response.headers.get("content-type") ?? "application/json"); + + return new NextResponse(response.body, { + status: response.status, + headers: responseHeaders + }); +} + +export const GET = handler; +export const POST = handler; +export const DELETE = handler; diff --git a/web/lib/simkl.ts b/web/lib/simkl.ts new file mode 100644 index 000000000..1f0ba63aa --- /dev/null +++ b/web/lib/simkl.ts @@ -0,0 +1,125 @@ +import { SyncClient, SyncMediaRef } from "./sync"; +import { loadStored, removeStored, saveStored } from "./storage"; +import { jsonRequest } from "./http"; + +const SIMKL_TOKEN_KEY = "arvio.web.simkl.token"; + +export interface SimklToken { + access_token: string; +} + +export interface SimklPinCode { + user_code: string; + verification_url: string; + expires_in: number; + interval: number; +} + +export class SimklClient implements SyncClient { + token: SimklToken | null = loadStored(SIMKL_TOKEN_KEY, null); + + get isConnected(): boolean { + return Boolean(this.token?.access_token); + } + + setToken(token: SimklToken | null) { + this.token = token; + if (this.token) saveStored(SIMKL_TOKEN_KEY, this.token); + else removeStored(SIMKL_TOKEN_KEY); + } + + private async simkl(path: string, options: RequestInit = {}): Promise { + const headers: Record = { + "content-type": "application/json", + ...(options.headers as Record) + }; + if (this.token?.access_token) { + headers["x-user-token"] = this.token.access_token; + } + return jsonRequest(`/api/simkl${path}`, { ...options, headers }); + } + + async beginPinAuth(): Promise { + return this.simkl("/oauth/pin"); + } + + async pollPinToken(userCode: string): Promise { + type PollRes = { result: string; access_token?: string }; + const res = await this.simkl(`/oauth/pin/${userCode}`); + if (res.result === "OK" && res.access_token) { + this.setToken({ access_token: res.access_token }); + return true; + } + return false; + } + + async watchlist(): Promise { + if (!this.isConnected) return []; + return this.simkl("/sync/all-items/movies"); + } + + async playback(): Promise { + return []; + } + + async watched(type: "movies" | "shows"): Promise { + if (!this.isConnected) return []; + return this.simkl(`/sync/all-items/${type}`); + } + + async addToWatchlist(item: SyncMediaRef): Promise { + if (!this.isConnected) return; + const body = item.mediaType === "movie" + ? { movies: [{ ids: { tmdb: item.tmdbId } }] } + : { shows: [{ ids: { tmdb: item.tmdbId } }] }; + await this.simkl("/sync/watchlist", { method: "POST", body: JSON.stringify(body) }); + } + + async removeFromWatchlist(item: SyncMediaRef): Promise { + if (!this.isConnected) return; + const body = item.mediaType === "movie" + ? { movies: [{ ids: { tmdb: item.tmdbId } }] } + : { shows: [{ ids: { tmdb: item.tmdbId } }] }; + await this.simkl("/sync/watchlist/remove", { method: "POST", body: JSON.stringify(body) }); + } + + async addToHistory(item: SyncMediaRef): Promise { + if (!this.isConnected) return; + const body = item.mediaType === "movie" + ? { movies: [{ ids: { tmdb: item.tmdbId } }] } + : { + shows: [{ + ids: { tmdb: item.tmdbId }, + seasons: item.season && item.episode ? [{ number: item.season, episodes: [{ number: item.episode }] }] : undefined + }] + }; + await this.simkl("/sync/history?allow_rewatch=yes", { method: "POST", body: JSON.stringify(body) }); + } + + async removeFromHistory(item: SyncMediaRef): Promise { + if (!this.isConnected) return; + const body = item.mediaType === "movie" + ? { movies: [{ ids: { tmdb: item.tmdbId } }] } + : { shows: [{ ids: { tmdb: item.tmdbId } }] }; + await this.simkl("/sync/history/remove", { method: "POST", body: JSON.stringify(body) }); + } + + async dismissFromContinueWatching(): Promise { + // No-op for Simkl + } + + async scrobble(action: "start" | "pause" | "stop", item: SyncMediaRef & { progress: number }): Promise { + if (!this.isConnected) return; + const normProgress = item.progress <= 1.0 ? item.progress * 100 : item.progress; + const body = item.mediaType === "movie" + ? { movie: { ids: { tmdb: item.tmdbId } }, progress: normProgress } + : { + show: { ids: { tmdb: item.tmdbId } }, + episode: item.episode ? { number: item.episode } : undefined, + progress: normProgress + }; + await this.simkl(`/scrobble/${action}`, { method: "POST", body: JSON.stringify(body) }); + } +} + +export const simklClient = new SimklClient(); diff --git a/web/lib/sync.ts b/web/lib/sync.ts index 530c016c1..959592b16 100644 --- a/web/lib/sync.ts +++ b/web/lib/sync.ts @@ -1,7 +1,8 @@ import { mdblistClient } from "./mdblist"; +import { simklClient } from "./simkl"; import { traktClient } from "./store"; -export type SyncProvider = "trakt" | "mdblist" | "none"; +export type SyncProvider = "trakt" | "mdblist" | "simkl" | "none"; export interface SyncMediaRef { mediaType: "movie" | "tv"; @@ -11,8 +12,7 @@ export interface SyncMediaRef { } /** - * The read/write surface shared by Trakt and MDBList. Both clients return reads - * in Trakt-compatible shapes, so the store's mappers work with either provider. + * The read/write surface shared by Trakt, MDBList, and Simkl. */ export interface SyncClient { readonly isConnected: boolean; @@ -27,14 +27,17 @@ export interface SyncClient { scrobble(action: "start" | "pause" | "stop", item: SyncMediaRef & { progress: number }): Promise; } -/** Which remote a profile is actively connected to (MDBList takes precedence). */ +/** Which remote a profile is actively connected to. */ export function activeSyncProvider(): SyncProvider { if (mdblistClient.isConnected) return "mdblist"; + if (simklClient.isConnected) return "simkl"; if (traktClient.isConnected) return "trakt"; return "none"; } -/** The active provider client, or the Trakt client when neither is connected. */ +/** The active provider client, or the Trakt client when none is connected. */ export function syncClient(): SyncClient { - return (mdblistClient.isConnected ? mdblistClient : traktClient) as unknown as SyncClient; + if (mdblistClient.isConnected) return mdblistClient as unknown as SyncClient; + if (simklClient.isConnected) return simklClient as unknown as SyncClient; + return traktClient as unknown as SyncClient; } From 30304de4665de4732418013aefa35d508c967337 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Tue, 11 Aug 2026 11:03:13 +0530 Subject: [PATCH 3/4] feat(android): add Simkl authentication, scrobbling, and sync support --- .../kotlin/com/arflix/tv/data/api/SimklApi.kt | 220 ++++++++++++++++++ .../data/repository/simkl/SimklAuthManager.kt | 58 +++++ .../data/repository/simkl/SimklScrobbler.kt | 115 +++++++++ .../data/repository/simkl/SimklSyncService.kt | 119 ++++++++++ .../data/repository/sync/RemoteSyncManager.kt | 4 +- .../repository/sync/SimklRemoteProvider.kt | 76 ++++++ .../tv/data/repository/sync/SyncProvider.kt | 4 +- .../data/repository/sync/SyncProviderStore.kt | 20 ++ .../main/kotlin/com/arflix/tv/di/AppModule.kt | 12 + .../ui/screens/settings/SettingsViewModel.kt | 65 +++++- .../kotlin/com/arflix/tv/util/Constants.kt | 3 + .../repository/simkl/SimklIntegrationTest.kt | 68 ++++++ secrets.defaults.properties | 2 + 13 files changed, 763 insertions(+), 3 deletions(-) create mode 100644 app/src/main/kotlin/com/arflix/tv/data/api/SimklApi.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklAuthManager.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklScrobbler.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklSyncService.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/repository/sync/SimklRemoteProvider.kt create mode 100644 app/src/test/kotlin/com/arflix/tv/data/repository/simkl/SimklIntegrationTest.kt diff --git a/app/src/main/kotlin/com/arflix/tv/data/api/SimklApi.kt b/app/src/main/kotlin/com/arflix/tv/data/api/SimklApi.kt new file mode 100644 index 000000000..e6af0e7a4 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/api/SimklApi.kt @@ -0,0 +1,220 @@ +package com.arflix.tv.data.api + +import com.google.gson.annotations.SerializedName +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +interface SimklApi { + + // ========== Authentication ========== + + @GET("oauth/pin") + suspend fun getPinCode( + @Query("client_id") clientId: String + ): SimklPinResponse + + @GET("oauth/pin/{code}") + suspend fun pollPinToken( + @Path("code") code: String, + @Query("client_id") clientId: String + ): SimklPinPollResponse + + // ========== Scrobble ========== + + @POST("scrobble/start") + suspend fun scrobbleStart( + @Header("Authorization") auth: String, + @Header("simkl-api-key") clientId: String, + @Body body: SimklScrobbleBody + ): Response + + @POST("scrobble/pause") + suspend fun scrobblePause( + @Header("Authorization") auth: String, + @Header("simkl-api-key") clientId: String, + @Body body: SimklScrobbleBody + ): Response + + @POST("scrobble/stop") + suspend fun scrobbleStop( + @Header("Authorization") auth: String, + @Header("simkl-api-key") clientId: String, + @Body body: SimklScrobbleBody + ): Response + + // ========== Sync & Watch History ========== + + @GET("sync/activities") + suspend fun getActivities( + @Header("Authorization") auth: String, + @Header("simkl-api-key") clientId: String + ): SimklActivitiesResponse + + @GET("sync/all-items/{type}") + suspend fun getAllItems( + @Header("Authorization") auth: String, + @Header("simkl-api-key") clientId: String, + @Path("type") type: String, // "movies", "shows", "anime" + @Query("date_from") dateFrom: String? = null + ): SimklAllItemsResponse + + @POST("sync/history") + suspend fun addToHistory( + @Header("Authorization") auth: String, + @Header("simkl-api-key") clientId: String, + @Body body: SimklSyncHistoryBody, + @Query("allow_rewatch") allowRewatch: String? = null + ): Response + + @POST("sync/history/remove") + suspend fun removeFromHistory( + @Header("Authorization") auth: String, + @Header("simkl-api-key") clientId: String, + @Body body: SimklSyncHistoryBody + ): Response + + @POST("sync/watchlist") + suspend fun addToWatchlist( + @Header("Authorization") auth: String, + @Header("simkl-api-key") clientId: String, + @Body body: SimklSyncWatchlistBody + ): Response + + @POST("sync/watchlist/remove") + suspend fun removeFromWatchlist( + @Header("Authorization") auth: String, + @Header("simkl-api-key") clientId: String, + @Body body: SimklSyncWatchlistBody + ): Response +} + +// Data Transfer Objects + +data class SimklPinResponse( + @SerializedName("user_code") val userCode: String, + @SerializedName("verification_url") val verificationUrl: String, + @SerializedName("expires_in") val expiresIn: Int = 600, + @SerializedName("interval") val interval: Int = 5, + @SerializedName("device_code") val deviceCode: String? = null +) + +data class SimklPinPollResponse( + @SerializedName("result") val result: String, // "KO", "pending", "OK" + @SerializedName("access_token") val accessToken: String? = null, + @SerializedName("token_type") val tokenType: String? = null, + @SerializedName("expires_in") val expiresIn: Long? = null +) + +data class SimklIds( + @SerializedName("simkl") val simkl: Long? = null, + @SerializedName("tmdb") val tmdb: Int? = null, + @SerializedName("imdb") val imdb: String? = null, + @SerializedName("tvdb") val tvdb: String? = null +) + +data class SimklMovieRef( + @SerializedName("title") val title: String? = null, + @SerializedName("year") val year: Int? = null, + @SerializedName("ids") val ids: SimklIds +) + +data class SimklEpisodeRef( + @SerializedName("number") val number: Int? = null, + @SerializedName("ids") val ids: SimklIds? = null +) + +data class SimklShowRef( + @SerializedName("title") val title: String? = null, + @SerializedName("year") val year: Int? = null, + @SerializedName("ids") val ids: SimklIds, + @SerializedName("seasons") val seasons: List? = null +) + +data class SimklSeasonRef( + @SerializedName("number") val number: Int, + @SerializedName("episodes") val episodes: List +) + +data class SimklScrobbleBody( + @SerializedName("movie") val movie: SimklMovieRef? = null, + @SerializedName("show") val show: SimklShowRef? = null, + @SerializedName("episode") val episode: SimklEpisodeRef? = null, + @SerializedName("progress") val progress: Float // 0.0 - 100.0 +) + +data class SimklScrobbleResponse( + @SerializedName("action") val action: String? = null, + @SerializedName("progress") val progress: Float? = null +) + +data class SimklActivitiesResponse( + @SerializedName("all") val all: String? = null, + @SerializedName("movies") val movies: SimklActivityGroup? = null, + @SerializedName("shows") val shows: SimklActivityGroup? = null, + @SerializedName("anime") val anime: SimklActivityGroup? = null +) + +data class SimklActivityGroup( + @SerializedName("all") val all: String? = null, + @SerializedName("watched_at") val watchedAt: String? = null, + @SerializedName("rated_at") val ratedAt: String? = null, + @SerializedName("plantowatch") val planToWatch: String? = null +) + +data class SimklAllItemsResponse( + @SerializedName("movies") val movies: List? = null, + @SerializedName("shows") val shows: List? = null, + @SerializedName("anime") val anime: List? = null +) + +data class SimklHistoryMovieItem( + @SerializedName("last_watched_at") val lastWatchedAt: String? = null, + @SerializedName("user_rating") val userRating: Int? = null, + @SerializedName("status") val status: String? = null, // "completed", "watching", "plantowatch", "hold", "dropped" + @SerializedName("movie") val movie: SimklMovieRef? = null +) + +data class SimklHistoryShowItem( + @SerializedName("last_watched_at") val lastWatchedAt: String? = null, + @SerializedName("status") val status: String? = null, + @SerializedName("show") val show: SimklShowRef? = null, + @SerializedName("seasons") val seasons: List? = null +) + +data class SimklHistorySeasonItem( + @SerializedName("number") val number: Int, + @SerializedName("episodes") val episodes: List +) + +data class SimklHistoryEpisodeItem( + @SerializedName("number") val number: Int, + @SerializedName("watched_at") val watchedAt: String? = null +) + +data class SimklSyncHistoryBody( + @SerializedName("movies") val movies: List? = null, + @SerializedName("shows") val shows: List? = null, + @SerializedName("episodes") val episodes: List? = null +) + +data class SimklSyncWatchlistBody( + @SerializedName("movies") val movies: List? = null, + @SerializedName("shows") val shows: List? = null +) + +data class SimklSyncResponse( + @SerializedName("added") val added: SimklSyncCount? = null, + @SerializedName("deleted") val deleted: SimklSyncCount? = null, + @SerializedName("not_found") val notFound: SimklSyncCount? = null +) + +data class SimklSyncCount( + @SerializedName("movies") val movies: Int = 0, + @SerializedName("shows") val shows: Int = 0, + @SerializedName("episodes") val episodes: Int = 0 +) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklAuthManager.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklAuthManager.kt new file mode 100644 index 000000000..55e781d33 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklAuthManager.kt @@ -0,0 +1,58 @@ +package com.arflix.tv.data.repository.simkl + +import com.arflix.tv.data.api.SimklApi +import com.arflix.tv.data.api.SimklPinResponse +import com.arflix.tv.data.repository.sync.SyncProvider +import com.arflix.tv.data.repository.sync.SyncProviderStore +import com.arflix.tv.util.Constants +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +sealed class SimklPinAuthState { + object Idle : SimklPinAuthState() + data class CodeRequested(val userCode: String, val verificationUrl: String, val expiresIn: Int) : SimklPinAuthState() + object Success : SimklPinAuthState() + data class Error(val message: String) : SimklPinAuthState() +} + +@Singleton +class SimklAuthManager @Inject constructor( + private val simklApi: SimklApi, + private val syncProviderStore: SyncProviderStore +) { + private val clientId: String get() = Constants.SIMKL_CLIENT_ID + + suspend fun getAccessToken(): String? { + return syncProviderStore.getSimklAccessToken() + } + + suspend fun isConnected(): Boolean { + val token = getAccessToken() + return !token.isNullOrBlank() + } + + suspend fun startPinAuth(): SimklPinResponse { + check(clientId.isNotBlank()) { "Simkl Client ID is missing" } + return simklApi.getPinCode(clientId) + } + + suspend fun pollPinAuth(userCode: String): Boolean { + check(clientId.isNotBlank()) { "Simkl Client ID is missing" } + val response = simklApi.pollPinToken(userCode, clientId) + if (response.result.equals("OK", ignoreCase = true) && !response.accessToken.isNullOrBlank()) { + syncProviderStore.setSimklAccessToken(response.accessToken) + syncProviderStore.setProvider(SyncProvider.SIMKL) + return true + } + return false + } + + suspend fun disconnect() { + syncProviderStore.setSimklAccessToken(null) + if (syncProviderStore.getProvider() == SyncProvider.SIMKL) { + syncProviderStore.setProvider(SyncProvider.NONE) + } + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklScrobbler.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklScrobbler.kt new file mode 100644 index 000000000..2fbae3ad7 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklScrobbler.kt @@ -0,0 +1,115 @@ +package com.arflix.tv.data.repository.simkl + +import com.arflix.tv.data.api.SimklApi +import com.arflix.tv.data.api.SimklEpisodeRef +import com.arflix.tv.data.api.SimklIds +import com.arflix.tv.data.api.SimklMovieRef +import com.arflix.tv.data.api.SimklScrobbleBody +import com.arflix.tv.data.api.SimklSeasonRef +import com.arflix.tv.data.api.SimklShowRef +import com.arflix.tv.data.model.MediaType +import com.arflix.tv.util.AppLogger +import com.arflix.tv.util.Constants +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SimklScrobbler @Inject constructor( + private val simklApi: SimklApi, + private val authManager: SimklAuthManager +) { + private val clientId: String get() = Constants.SIMKL_CLIENT_ID + + private fun normalizeProgress(progress: Float): Float { + // If progress is in 0.0 - 1.0 range, scale to 0.0 - 100.0 + return if (progress in 0.0f..1.0f) progress * 100f else progress.coerceIn(0f, 100f) + } + + suspend fun scrobbleStart( + mediaType: MediaType, + tmdbId: Int, + progress: Float, + season: Int? = null, + episode: Int? = null + ) { + val token = authManager.getAccessToken() ?: return + val authHeader = "Bearer $token" + val body = buildScrobbleBody(mediaType, tmdbId, progress, season, episode) + + try { + simklApi.scrobbleStart(authHeader, clientId, body) + } catch (e: Exception) { + AppLogger.e("SimklScrobbler", "Error scrobbling start for tmdbId=$tmdbId: ${e.message}") + } + } + + suspend fun scrobblePause( + mediaType: MediaType, + tmdbId: Int, + progress: Float, + season: Int? = null, + episode: Int? = null + ) { + val token = authManager.getAccessToken() ?: return + val authHeader = "Bearer $token" + val body = buildScrobbleBody(mediaType, tmdbId, progress, season, episode) + + try { + simklApi.scrobblePause(authHeader, clientId, body) + } catch (e: Exception) { + AppLogger.e("SimklScrobbler", "Error scrobbling pause for tmdbId=$tmdbId: ${e.message}") + } + } + + suspend fun scrobbleStop( + mediaType: MediaType, + tmdbId: Int, + progress: Float, + season: Int? = null, + episode: Int? = null + ) { + val token = authManager.getAccessToken() ?: return + val authHeader = "Bearer $token" + val body = buildScrobbleBody(mediaType, tmdbId, progress, season, episode) + + try { + simklApi.scrobbleStop(authHeader, clientId, body) + } catch (e: Exception) { + AppLogger.e("SimklScrobbler", "Error scrobbling stop for tmdbId=$tmdbId: ${e.message}") + } + } + + private fun buildScrobbleBody( + mediaType: MediaType, + tmdbId: Int, + progress: Float, + season: Int?, + episode: Int? + ): SimklScrobbleBody { + val normProgress = normalizeProgress(progress) + return if (mediaType == MediaType.MOVIE) { + SimklScrobbleBody( + movie = SimklMovieRef(ids = SimklIds(tmdb = tmdbId)), + progress = normProgress + ) + } else { + SimklScrobbleBody( + show = SimklShowRef( + ids = SimklIds(tmdb = tmdbId), + seasons = if (season != null && episode != null) { + listOf( + SimklSeasonRef( + number = season, + episodes = listOf(SimklEpisodeRef(number = episode)) + ) + ) + } else null + ), + episode = if (season != null && episode != null) { + SimklEpisodeRef(number = episode) + } else null, + progress = normProgress + ) + } + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklSyncService.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklSyncService.kt new file mode 100644 index 000000000..5823d84eb --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklSyncService.kt @@ -0,0 +1,119 @@ +package com.arflix.tv.data.repository.simkl + +import com.arflix.tv.data.api.SimklApi +import com.arflix.tv.data.api.SimklEpisodeRef +import com.arflix.tv.data.api.SimklIds +import com.arflix.tv.data.api.SimklMovieRef +import com.arflix.tv.data.api.SimklSeasonRef +import com.arflix.tv.data.api.SimklShowRef +import com.arflix.tv.data.api.SimklSyncHistoryBody +import com.arflix.tv.data.api.SimklSyncWatchlistBody +import com.arflix.tv.data.model.MediaType +import com.arflix.tv.util.AppLogger +import com.arflix.tv.util.Constants +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SimklSyncService @Inject constructor( + private val simklApi: SimklApi, + private val authManager: SimklAuthManager +) { + private val clientId: String get() = Constants.SIMKL_CLIENT_ID + + suspend fun getWatchedMovies(): Set { + val token = authManager.getAccessToken() ?: return emptySet() + val authHeader = "Bearer $token" + return try { + val response = simklApi.getAllItems(authHeader, clientId, "movies") + response.movies + ?.filter { it.status == "completed" || it.status == "watching" } + ?.mapNotNull { it.movie?.ids?.tmdb } + ?.toSet() ?: emptySet() + } catch (e: Exception) { + AppLogger.e("SimklSyncService", "Error fetching watched movies: ${e.message}") + emptySet() + } + } + + suspend fun getWatchedEpisodes(): Set { + val token = authManager.getAccessToken() ?: return emptySet() + val authHeader = "Bearer $token" + return try { + val response = simklApi.getAllItems(authHeader, clientId, "shows") + val watched = mutableSetOf() + response.shows?.forEach { showItem -> + val showTmdb = showItem.show?.ids?.tmdb ?: return@forEach + showItem.seasons?.forEach { season -> + season.episodes.forEach { episode -> + watched.add("${showTmdb}_S${season.number}_E${episode.number}") + } + } + } + watched + } catch (e: Exception) { + AppLogger.e("SimklSyncService", "Error fetching watched episodes: ${e.message}") + emptySet() + } + } + + suspend fun addToWatchlist(mediaType: MediaType, tmdbId: Int): Boolean { + val token = authManager.getAccessToken() ?: return false + val authHeader = "Bearer $token" + val body = if (mediaType == MediaType.MOVIE) { + SimklSyncWatchlistBody(movies = listOf(SimklMovieRef(ids = SimklIds(tmdb = tmdbId)))) + } else { + SimklSyncWatchlistBody(shows = listOf(SimklShowRef(ids = SimklIds(tmdb = tmdbId)))) + } + return try { + val res = simklApi.addToWatchlist(authHeader, clientId, body) + res.isSuccessful + } catch (e: Exception) { + AppLogger.e("SimklSyncService", "Error adding to watchlist: ${e.message}") + false + } + } + + suspend fun removeFromWatchlist(mediaType: MediaType, tmdbId: Int): Boolean { + val token = authManager.getAccessToken() ?: return false + val authHeader = "Bearer $token" + val body = if (mediaType == MediaType.MOVIE) { + SimklSyncWatchlistBody(movies = listOf(SimklMovieRef(ids = SimklIds(tmdb = tmdbId)))) + } else { + SimklSyncWatchlistBody(shows = listOf(SimklShowRef(ids = SimklIds(tmdb = tmdbId)))) + } + return try { + val res = simklApi.removeFromWatchlist(authHeader, clientId, body) + res.isSuccessful + } catch (e: Exception) { + AppLogger.e("SimklSyncService", "Error removing from watchlist: ${e.message}") + false + } + } + + suspend fun markWatched(mediaType: MediaType, tmdbId: Int, season: Int? = null, episode: Int? = null): Boolean { + val token = authManager.getAccessToken() ?: return false + val authHeader = "Bearer $token" + val body = if (mediaType == MediaType.MOVIE) { + SimklSyncHistoryBody(movies = listOf(SimklMovieRef(ids = SimklIds(tmdb = tmdbId)))) + } else { + SimklSyncHistoryBody( + shows = listOf( + SimklShowRef( + ids = SimklIds(tmdb = tmdbId), + seasons = if (season != null && episode != null) { + listOf(SimklSeasonRef(number = season, episodes = listOf(SimklEpisodeRef(number = episode)))) + } else null + ) + ) + ) + } + return try { + val res = simklApi.addToHistory(authHeader, clientId, body, allowRewatch = "yes") + res.isSuccessful + } catch (e: Exception) { + AppLogger.e("SimklSyncService", "Error marking watched: ${e.message}") + false + } + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/sync/RemoteSyncManager.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/sync/RemoteSyncManager.kt index 33a95b79a..cb86fc337 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/sync/RemoteSyncManager.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/sync/RemoteSyncManager.kt @@ -19,7 +19,8 @@ import javax.inject.Singleton class RemoteSyncManager @Inject constructor( private val store: SyncProviderStore, private val traktProvider: TraktRemoteProvider, - private val mdbListProvider: MdbListRemoteProvider + private val mdbListProvider: MdbListRemoteProvider, + private val simklProvider: SimklRemoteProvider ) { /** The provider explicitly selected for this profile (may be NONE). */ suspend fun selectedProvider(): SyncProvider = store.getProvider() @@ -31,6 +32,7 @@ class RemoteSyncManager @Inject constructor( suspend fun active(): RemoteSyncProvider? { val candidate = when (store.getProvider()) { SyncProvider.MDBLIST -> mdbListProvider + SyncProvider.SIMKL -> simklProvider // TRAKT or NONE (legacy: infer Trakt from an existing token). SyncProvider.TRAKT, SyncProvider.NONE -> traktProvider } diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SimklRemoteProvider.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SimklRemoteProvider.kt new file mode 100644 index 000000000..1133c82c9 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SimklRemoteProvider.kt @@ -0,0 +1,76 @@ +package com.arflix.tv.data.repository.sync + +import com.arflix.tv.data.model.MediaType +import com.arflix.tv.data.repository.ContinueWatchingItem +import com.arflix.tv.data.repository.simkl.SimklAuthManager +import com.arflix.tv.data.repository.simkl.SimklScrobbler +import com.arflix.tv.data.repository.simkl.SimklSyncService +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Simkl implementation of [RemoteSyncProvider]. + */ +@Singleton +class SimklRemoteProvider @Inject constructor( + private val authManager: SimklAuthManager, + private val scrobbler: SimklScrobbler, + private val syncService: SimklSyncService +) : RemoteSyncProvider { + + override val provider: SyncProvider = SyncProvider.SIMKL + + override suspend fun isConnected(): Boolean = authManager.isConnected() + + override suspend fun addToWatchlist(mediaType: MediaType, tmdbId: Int): Boolean = + syncService.addToWatchlist(mediaType, tmdbId) + + override suspend fun removeFromWatchlist(mediaType: MediaType, tmdbId: Int): Boolean = + syncService.removeFromWatchlist(mediaType, tmdbId) + + override suspend fun getWatchlist(): RemoteWatchlistResult { + val connected = isConnected() + return RemoteWatchlistResult( + connected = connected, + items = emptyList(), + rawCount = 0 + ) + } + + override suspend fun scrobbleStart( + mediaType: MediaType, + tmdbId: Int, + progress: Float, + season: Int?, + episode: Int? + ) { + scrobbler.scrobbleStart(mediaType, tmdbId, progress, season, episode) + } + + override suspend fun scrobblePause( + mediaType: MediaType, + tmdbId: Int, + progress: Float, + season: Int?, + episode: Int? + ) { + scrobbler.scrobblePause(mediaType, tmdbId, progress, season, episode) + } + + override suspend fun scrobbleStop( + mediaType: MediaType, + tmdbId: Int, + progress: Float, + season: Int?, + episode: Int? + ) { + scrobbler.scrobbleStop(mediaType, tmdbId, progress, season, episode) + } + + override suspend fun getWatchedMovies(): Set = syncService.getWatchedMovies() + + override suspend fun getWatchedEpisodes(): Set = syncService.getWatchedEpisodes() + + override suspend fun getContinueWatching(forceRefresh: Boolean): List = + emptyList() +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SyncProvider.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SyncProvider.kt index 4c06feea2..436f0eb72 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SyncProvider.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SyncProvider.kt @@ -7,12 +7,14 @@ package com.arflix.tv.data.repository.sync enum class SyncProvider { NONE, TRAKT, - MDBLIST; + MDBLIST, + SIMKL; companion object { fun fromStorage(value: String?): SyncProvider = when (value?.lowercase()) { "trakt" -> TRAKT "mdblist" -> MDBLIST + "simkl" -> SIMKL else -> NONE } } diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SyncProviderStore.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SyncProviderStore.kt index 4518c3565..66d3e2354 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SyncProviderStore.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/sync/SyncProviderStore.kt @@ -55,6 +55,26 @@ class SyncProviderStore @Inject constructor( } } + private fun simklAccessTokenKey() = profileManager.profileStringKey("simkl_access_token") + private fun simklAccessTokenKeyFor(profileId: String) = + profileManager.profileStringKeyFor(profileId, "simkl_access_token") + + suspend fun getSimklAccessToken(): String? { + val prefs = context.traktDataStore.data.first() + return prefs[simklAccessTokenKey()]?.trim()?.takeIf { it.isNotEmpty() } + } + + suspend fun setSimklAccessToken(token: String?) { + context.traktDataStore.edit { prefs -> + val trimmed = token?.trim().orEmpty() + if (trimmed.isEmpty()) { + prefs.remove(simklAccessTokenKey()) + } else { + prefs[simklAccessTokenKey()] = trimmed + } + } + } + suspend fun getMdbListApiKey(): String? { val prefs = context.traktDataStore.data.first() return prefs[mdbListKey()]?.trim()?.takeIf { it.isNotEmpty() } diff --git a/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt b/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt index 92dfe5c6e..1e83b00f7 100644 --- a/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt +++ b/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt @@ -88,6 +88,18 @@ object AppModule { .create(com.arflix.tv.data.api.MdbListApi::class.java) } + @Provides + @Singleton + @JvmStatic + fun provideSimklApi(okHttpClient: OkHttpClient): com.arflix.tv.data.api.SimklApi { + return Retrofit.Builder() + .baseUrl(Constants.SIMKL_BASE_URL) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(com.arflix.tv.data.api.SimklApi::class.java) + } + @Provides @Singleton @JvmStatic diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index 6960e492a..030fe6d71 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -143,6 +143,12 @@ data class SettingsUiState( val isMdbListConnected: Boolean = false, val mdbListConnecting: Boolean = false, val mdbListUsername: String? = null, + // Simkl (alternative remote sync provider) + val isSimklConnected: Boolean = false, + val isSimklAuthStarting: Boolean = false, + val isSimklPolling: Boolean = false, + val simklUserCode: String? = null, + val simklVerificationUrl: String? = null, // Trakt Sync val isSyncing: Boolean = false, val syncProgress: SyncProgress = SyncProgress(), @@ -245,7 +251,8 @@ class SettingsViewModel @Inject constructor( private val apkDownloader: ApkDownloader, private val updateStatusManager: com.arflix.tv.updater.UpdateStatusManager, private val mdbListRepository: com.arflix.tv.data.repository.MdbListRepository, - private val syncProviderStore: com.arflix.tv.data.repository.sync.SyncProviderStore + private val syncProviderStore: com.arflix.tv.data.repository.sync.SyncProviderStore, + private val simklAuthManager: com.arflix.tv.data.repository.simkl.SimklAuthManager ) : ViewModel() { private fun visibleCatalogs(catalogs: List): List { return catalogs.filter { config -> @@ -3501,6 +3508,62 @@ class SettingsViewModel @Inject constructor( } } + // ========== Simkl Authentication ========== + + fun startSimklAuth() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isSimklAuthStarting = true) + runCatching { + val pinRes = simklAuthManager.startPinAuth() + _uiState.value = _uiState.value.copy( + isSimklAuthStarting = false, + isSimklPolling = true, + simklUserCode = pinRes.userCode, + simklVerificationUrl = pinRes.verificationUrl + ) + }.onFailure { e -> + _uiState.value = _uiState.value.copy( + isSimklAuthStarting = false, + toastMessage = "Simkl Auth Error: ${e.message}", + toastType = ToastType.ERROR + ) + } + } + } + + fun pollSimklAuth() { + val userCode = _uiState.value.simklUserCode ?: return + viewModelScope.launch { + runCatching { + val success = simklAuthManager.pollPinAuth(userCode) + if (success) { + _uiState.value = _uiState.value.copy( + isSimklPolling = false, + isSimklConnected = true, + simklUserCode = null, + simklVerificationUrl = null, + toastMessage = "Connected to Simkl!", + toastType = ToastType.SUCCESS + ) + } + } + } + } + + fun disconnectSimkl() { + viewModelScope.launch { + simklAuthManager.disconnect() + _uiState.value = _uiState.value.copy( + isSimklConnected = false, + isSimklPolling = false, + simklUserCode = null, + simklVerificationUrl = null, + toastMessage = "Disconnected from Simkl", + toastType = ToastType.SUCCESS + ) + } + } + fun dismissToast() { _uiState.value = _uiState.value.copy(toastMessage = null) } diff --git a/app/src/main/kotlin/com/arflix/tv/util/Constants.kt b/app/src/main/kotlin/com/arflix/tv/util/Constants.kt index 70c206fa7..64505a53b 100644 --- a/app/src/main/kotlin/com/arflix/tv/util/Constants.kt +++ b/app/src/main/kotlin/com/arflix/tv/util/Constants.kt @@ -37,6 +37,7 @@ object Constants { // API base URLs. const val TMDB_BASE_URL = "https://api.themoviedb.org/3/" const val TRAKT_API_URL = "https://api.trakt.tv/" + const val SIMKL_BASE_URL = "https://api.simkl.com/" // MDBList is an optional per-profile alternative to Trakt. Auth is a static // API key passed as an `?apikey=` query parameter (no OAuth), so no client // secret needs to ship in the APK. @@ -53,6 +54,8 @@ object Constants { val TRAKT_CLIENT_ID: String get() = usableSecret(BuildConfig.TRAKT_CLIENT_ID) val TRAKT_CLIENT_SECRET: String get() = usableSecret(BuildConfig.TRAKT_CLIENT_SECRET) + val SIMKL_CLIENT_ID: String get() = usableSecret(BuildConfig.SIMKL_CLIENT_ID) + val SIMKL_CLIENT_SECRET: String get() = usableSecret(BuildConfig.SIMKL_CLIENT_SECRET) // Image URLs - tuned for TV quality with smooth scrolling/perf. const val IMAGE_BASE = "https://image.tmdb.org/t/p/w780" diff --git a/app/src/test/kotlin/com/arflix/tv/data/repository/simkl/SimklIntegrationTest.kt b/app/src/test/kotlin/com/arflix/tv/data/repository/simkl/SimklIntegrationTest.kt new file mode 100644 index 000000000..e81f2caef --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/simkl/SimklIntegrationTest.kt @@ -0,0 +1,68 @@ +package com.arflix.tv.data.repository.simkl + +import com.arflix.tv.data.api.SimklApi +import com.arflix.tv.data.api.SimklPinPollResponse +import com.arflix.tv.data.api.SimklPinResponse +import com.arflix.tv.data.model.MediaType +import com.arflix.tv.data.repository.sync.SyncProvider +import com.arflix.tv.data.repository.sync.SyncProviderStore +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock + +class SimklIntegrationTest { + + private lateinit var simklApi: SimklApi + private lateinit var syncProviderStore: SyncProviderStore + private lateinit var authManager: SimklAuthManager + private lateinit var scrobbler: SimklScrobbler + private lateinit var syncService: SimklSyncService + + @Before + fun setUp() { + simklApi = mock(SimklApi::class.java) + syncProviderStore = mock(SyncProviderStore::class.java) + authManager = SimklAuthManager(simklApi, syncProviderStore) + scrobbler = SimklScrobbler(simklApi, authManager) + syncService = SimklSyncService(simklApi, authManager) + } + + @Test + fun testStartPinAuthReturnsResponse() = runBlocking { + val expected = SimklPinResponse( + userCode = "SIMKL-123", + verificationUrl = "https://simkl.com/pin", + expiresIn = 600 + ) + `when`(simklApi.getPinCode(anyString())).thenReturn(expected) + + val result = authManager.startPinAuth() + assertEquals("SIMKL-123", result.userCode) + assertEquals("https://simkl.com/pin", result.verificationUrl) + } + + @Test + fun testPollPinAuthSuccessStoresToken() = runBlocking { + val pollRes = SimklPinPollResponse( + result = "OK", + accessToken = "token_abc123" + ) + `when`(simklApi.pollPinToken(anyString(), anyString())).thenReturn(pollRes) + + val success = authManager.pollPinAuth("SIMKL-123") + assertTrue(success) + } + + @Test + fun testDisconnectClearsToken() = runBlocking { + authManager.disconnect() + `when`(syncProviderStore.getSimklAccessToken()).thenReturn(null) + assertFalse(authManager.isConnected()) + } +} diff --git a/secrets.defaults.properties b/secrets.defaults.properties index 5cd5f2764..8629396cb 100644 --- a/secrets.defaults.properties +++ b/secrets.defaults.properties @@ -13,3 +13,5 @@ SENTRY_DSN=disabled TMDB_API_KEY=your-tmdb-api-key TRAKT_CLIENT_ID=your-trakt-client-id TRAKT_CLIENT_SECRET=your-trakt-client-secret +SIMKL_CLIENT_ID=your-simkl-client-id +SIMKL_CLIENT_SECRET=your-simkl-client-secret From e55fa6e2dcff3d885d8b6b5c52e55bf69f8dfcae Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Tue, 11 Aug 2026 21:08:17 +0530 Subject: [PATCH 4/4] feat(android): add active Simkl UI, Netlify proxy interceptor, and auth manager --- app/build.gradle.kts | 6 +- .../data/repository/simkl/SimklAuthManager.kt | 8 +- .../arflix/tv/network/ApiProxyInterceptor.kt | 32 +++++ .../tv/ui/screens/settings/SettingsScreen.kt | 116 +++++++++++++----- .../kotlin/com/arflix/tv/util/Constants.kt | 1 + 5 files changed, 129 insertions(+), 34 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ae738ed7c..33d641da3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -44,7 +44,11 @@ android { buildConfigField("Boolean", "ENABLE_PERIODIC_CLOUD_PULL", "false") buildConfigField("Boolean", "ENABLE_NETLIFY_CLOUD_SYNC", "true") buildConfigField("Boolean", "ENABLE_SUPABASE_SYNC_MIRROR", "false") - buildConfigField("String", "NETLIFY_BACKEND_URL", "\"https://auth.arvio.tv/.netlify/functions\"") + buildConfigField( + "String", + "NETLIFY_BACKEND_URL", + "\"${escapeBuildConfigString(localSecretValue("NETLIFY_BACKEND_URL").ifBlank { "https://simkl-backend--arvio-auth.netlify.app/.netlify/functions" })}\"" + ) buildConfigField( "String", "APP_ANON_KEY", diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklAuthManager.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklAuthManager.kt index 55e781d33..db6ea2c6e 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklAuthManager.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklAuthManager.kt @@ -34,13 +34,13 @@ class SimklAuthManager @Inject constructor( } suspend fun startPinAuth(): SimklPinResponse { - check(clientId.isNotBlank()) { "Simkl Client ID is missing" } - return simklApi.getPinCode(clientId) + val effectiveClientId = clientId.ifBlank { "simkl_proxy" } + return simklApi.getPinCode(effectiveClientId) } suspend fun pollPinAuth(userCode: String): Boolean { - check(clientId.isNotBlank()) { "Simkl Client ID is missing" } - val response = simklApi.pollPinToken(userCode, clientId) + val effectiveClientId = clientId.ifBlank { "simkl_proxy" } + val response = simklApi.pollPinToken(userCode, effectiveClientId) if (response.result.equals("OK", ignoreCase = true) && !response.accessToken.isNullOrBlank()) { syncProviderStore.setSimklAccessToken(response.accessToken) syncProviderStore.setProvider(SyncProvider.SIMKL) diff --git a/app/src/main/kotlin/com/arflix/tv/network/ApiProxyInterceptor.kt b/app/src/main/kotlin/com/arflix/tv/network/ApiProxyInterceptor.kt index 5135009a3..b19db419a 100644 --- a/app/src/main/kotlin/com/arflix/tv/network/ApiProxyInterceptor.kt +++ b/app/src/main/kotlin/com/arflix/tv/network/ApiProxyInterceptor.kt @@ -44,6 +44,10 @@ class ApiProxyInterceptor : Interceptor { // user API key on the query string. Keep it direct, same as Trakt. chain.proceed(originalRequest) } + "api.simkl.com" -> { + val proxyRequest = rewriteForSimklProxy(originalRequest) ?: originalRequest + chain.proceed(proxyRequest) + } else -> { // Pass through other requests unchanged chain.proceed(originalRequest) @@ -51,6 +55,34 @@ class ApiProxyInterceptor : Interceptor { } } + private fun rewriteForSimklProxy(originalRequest: Request): Request? { + val originalUrl = originalRequest.url + val path = originalUrl.encodedPath + + val proxyUrlBuilder = (Constants.SIMKL_PROXY_URL.toHttpUrlOrNull() ?: return null).newBuilder() + .addQueryParameter("path", path) + .addQueryParameter("method", originalRequest.method) + + for (i in 0 until originalUrl.querySize) { + val name = originalUrl.queryParameterName(i) + originalUrl.queryParameterValue(i)?.let { value -> + proxyUrlBuilder.addQueryParameter(name, value) + } + } + + val userToken = originalRequest.header("Authorization")?.removePrefix("Bearer ") + val builder = originalRequest.newBuilder() + .url(proxyUrlBuilder.build()) + .header("apikey", Constants.APP_ANON_KEY) + .header("Authorization", "Bearer ${Constants.APP_ANON_KEY}") + + if (!userToken.isNullOrBlank()) { + builder.header("x-user-token", userToken) + } + + return builder.build() + } + private fun rewriteForTmdbProxy(originalRequest: Request): Request? { val originalUrl = originalRequest.url diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index a8da05cd8..ea95fb331 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -459,7 +459,7 @@ fun SettingsScreen( "catalogs" -> uiState.catalogs.size + 1 // Add + Import + catalogs "stremio" -> stremioAddons.size + 1 // rows + refresh + add button "plugins" -> pluginsMaxIndex - "accounts" -> 8 // Cloud, integrations, sync, update, diagnostics, privacy, deletion + "accounts" -> 9 // Cloud, Trakt, MDBList, Simkl, Telegram, sync, update, diagnostics, privacy, deletion else -> 0 } } @@ -1125,7 +1125,6 @@ fun SettingsScreen( } contentFocusIndex == stremioAddons.size -> { viewModel.refreshAddons() - } else -> { showCustomAddonInput = true } @@ -1156,18 +1155,25 @@ fun SettingsScreen( showMdbListConnect = true } } - 3 -> onNavigateToTelegramSettings() - 4 -> viewModel.forceCloudSyncNow() - 5 -> { + 3 -> { + if (uiState.isSimklConnected || uiState.isSimklPolling) { + viewModel.disconnectSimkl() + } else { + viewModel.startSimklAuth() + } + } + 4 -> onNavigateToTelegramSettings() + 5 -> viewModel.forceCloudSyncNow() + 6 -> { if (uiState.updateStatus is com.arflix.tv.updater.UpdateStatus.ReadyToInstall) { viewModel.installAppUpdateOrRequestPermission() } else { viewModel.checkForAppUpdates(force = true, showNoUpdateFeedback = true) } } - 6 -> viewModel.setDiagnosticsSharingEnabled(!uiState.diagnosticsSharingEnabled) - 7 -> openExternalUrl(context, PRIVACY_POLICY_URL) - 8 -> openExternalUrl(context, ACCOUNT_DELETION_URL) + 7 -> viewModel.setDiagnosticsSharingEnabled(!uiState.diagnosticsSharingEnabled) + 8 -> openExternalUrl(context, PRIVACY_POLICY_URL) + 9 -> openExternalUrl(context, ACCOUNT_DELETION_URL) } } "plugins" -> { @@ -1694,6 +1700,13 @@ fun SettingsScreen( isMdbListConnected = uiState.isMdbListConnected, onConnectMdbList = { showMdbListConnect = true }, onDisconnectMdbList = { showMdbListDisconnectConfirm = true }, + isSimklConnected = uiState.isSimklConnected, + simklCode = uiState.simklUserCode, + simklUrl = uiState.simklVerificationUrl, + isSimklAuthStarting = uiState.isSimklAuthStarting, + isSimklPolling = uiState.isSimklPolling, + onConnectSimkl = { viewModel.startSimklAuth() }, + onDisconnectSimkl = { viewModel.disconnectSimkl() }, onForceCloudSync = { viewModel.forceCloudSyncNow() }, onSwitchProfile = onSwitchProfile, onCheckUpdates = { viewModel.checkForAppUpdates(force = true, showNoUpdateFeedback = true) }, @@ -2218,6 +2231,17 @@ fun SettingsScreen( ) } + uiState.simklUserCode?.let { simklCode -> + val verificationUrl = uiState.simklVerificationUrl ?: "https://simkl.com/pin" + TraktActivationModal( + title = "Connect Simkl", + instruction = "Visit $verificationUrl on your phone or computer and enter this code:", + verificationUrl = verificationUrl, + userCode = simklCode, + onDismiss = { viewModel.disconnectSimkl() } + ) + } + uiState.plexHomeServerAuth?.let { plexAuth -> TraktActivationModal( title = stringResource(R.string.settings_connect_with_code), @@ -4284,7 +4308,9 @@ private fun MobileSettingsSubPage( onConnectTrakt = onConnectTrakt, onDisconnectTrakt = onDisconnectTrakt, onConnectMdbList = onConnectMdbList, - onDisconnectMdbList = onDisconnectMdbList + onDisconnectMdbList = onDisconnectMdbList, + onConnectSimkl = { viewModel.startSimklAuth() }, + onDisconnectSimkl = { viewModel.disconnectSimkl() } ) } } @@ -7912,6 +7938,13 @@ private fun AccountsSettings( isMdbListConnected: Boolean, onConnectMdbList: () -> Unit, onDisconnectMdbList: () -> Unit, + isSimklConnected: Boolean = false, + simklCode: String? = null, + simklUrl: String? = null, + isSimklAuthStarting: Boolean = false, + isSimklPolling: Boolean = false, + onConnectSimkl: () -> Unit = {}, + onDisconnectSimkl: () -> Unit = {}, isForceCloudSyncing: Boolean, lastCloudSyncStatus: String?, diagnosticsSharingEnabled: Boolean, @@ -7994,14 +8027,31 @@ private fun AccountsSettings( Spacer(modifier = Modifier.height(16.dp)) + // Simkl + AccountRow( + name = "Simkl", + description = stringResource(R.string.settings_simkl_tagline), + isConnected = isSimklConnected, + isWorking = isSimklAuthStarting || isSimklPolling, + authCode = simklCode, + authUrl = simklUrl, + isFocused = focusedIndex == 3, + onConnect = { if (isSimklPolling) onDisconnectSimkl() else onConnectSimkl() }, + onDisconnect = onDisconnectSimkl, + modifier = Modifier.settingsFocusSlot(3), + expirationText = null + ) + + Spacer(modifier = Modifier.height(16.dp)) + // Telegram SettingsActionRow( title = "Telegram", description = stringResource(R.string.settings_telegram_desc), actionLabel = stringResource(R.string.settings_badge_open), - isFocused = focusedIndex == 3, + isFocused = focusedIndex == 4, onClick = onNavigateToTelegram, - modifier = Modifier.settingsFocusSlot(3) + modifier = Modifier.settingsFocusSlot(4) ) Spacer(modifier = Modifier.height(16.dp)) @@ -8018,9 +8068,9 @@ private fun AccountsSettings( stringResource(R.string.settings_signin_to_force_sync) }, actionLabel = if (isForceCloudSyncing) stringResource(R.string.settings_badge_syncing) else stringResource(R.string.settings_badge_sync), - isFocused = focusedIndex == 4, + isFocused = focusedIndex == 5, onClick = { if (!isForceCloudSyncing) onForceCloudSync() }, - modifier = Modifier.settingsFocusSlot(4) + modifier = Modifier.settingsFocusSlot(5) ) Spacer(modifier = Modifier.height(16.dp)) @@ -8042,11 +8092,11 @@ private fun AccountsSettings( updateStatus is com.arflix.tv.updater.UpdateStatus.UpdateAvailable -> stringResource(R.string.settings_badge_update) else -> stringResource(R.string.settings_badge_check) }, - isFocused = focusedIndex == 5, + isFocused = focusedIndex == 6, onClick = { if (updateStatus is com.arflix.tv.updater.UpdateStatus.ReadyToInstall) onInstallUpdate() else onCheckUpdates() }, - modifier = Modifier.settingsFocusSlot(5) + modifier = Modifier.settingsFocusSlot(6) ) Spacer(modifier = Modifier.height(16.dp)) @@ -8055,9 +8105,9 @@ private fun AccountsSettings( title = stringResource(R.string.settings_diagnostics_sharing), subtitle = stringResource(R.string.settings_diagnostics_sharing_desc), isEnabled = diagnosticsSharingEnabled, - isFocused = focusedIndex == 6, + isFocused = focusedIndex == 7, onToggle = onDiagnosticsSharingToggle, - modifier = Modifier.settingsFocusSlot(6) + modifier = Modifier.settingsFocusSlot(7) ) Spacer(modifier = Modifier.height(16.dp)) @@ -8066,9 +8116,9 @@ private fun AccountsSettings( title = stringResource(R.string.settings_privacy_policy), description = stringResource(R.string.settings_privacy_policy_desc), actionLabel = stringResource(R.string.settings_badge_open), - isFocused = focusedIndex == 7, + isFocused = focusedIndex == 8, onClick = onOpenPrivacy, - modifier = Modifier.settingsFocusSlot(7) + modifier = Modifier.settingsFocusSlot(8) ) Spacer(modifier = Modifier.height(16.dp)) @@ -8077,9 +8127,9 @@ private fun AccountsSettings( title = stringResource(R.string.settings_account_data_deletion), description = stringResource(R.string.settings_account_data_deletion_desc), actionLabel = stringResource(R.string.settings_badge_open), - isFocused = focusedIndex == 8, + isFocused = focusedIndex == 9, onClick = onOpenDataDeletion, - modifier = Modifier.settingsFocusSlot(8) + modifier = Modifier.settingsFocusSlot(9) ) } } @@ -8385,7 +8435,9 @@ private fun TrackingIntegrationsPage( onConnectTrakt: () -> Unit, onDisconnectTrakt: () -> Unit, onConnectMdbList: (String) -> Unit, - onDisconnectMdbList: () -> Unit + onDisconnectMdbList: () -> Unit, + onConnectSimkl: () -> Unit = {}, + onDisconnectSimkl: () -> Unit = {} ) { var showMdbListConnect by remember { mutableStateOf(false) } var showMdbListDisconnectConfirm by remember { mutableStateOf(false) } @@ -8511,18 +8563,24 @@ private fun TrackingIntegrationsPage( onDisconnect = { showMdbListDisconnectConfirm = true } ) - // Simkl - coming soon + // Simkl TrackingServiceRow( iconRes = R.drawable.ic_simkl, title = "Simkl", tagline = stringResource(R.string.settings_simkl_tagline), - isConnected = false, - isWorking = false, - connectedAs = null, - comingSoon = true, + isConnected = uiState.isSimklConnected, + isWorking = uiState.isSimklAuthStarting || uiState.isSimklPolling, + connectedAs = if (uiState.isSimklConnected) "Connected" else null, + comingSoon = false, showDivider = false, - onConnect = {}, - onDisconnect = {} + onConnect = { + if (uiState.isSimklPolling || uiState.isSimklConnected) { + onDisconnectSimkl() + } else { + onConnectSimkl() + } + }, + onDisconnect = onDisconnectSimkl ) } } diff --git a/app/src/main/kotlin/com/arflix/tv/util/Constants.kt b/app/src/main/kotlin/com/arflix/tv/util/Constants.kt index 64505a53b..4ce765fbc 100644 --- a/app/src/main/kotlin/com/arflix/tv/util/Constants.kt +++ b/app/src/main/kotlin/com/arflix/tv/util/Constants.kt @@ -20,6 +20,7 @@ object Constants { // Edge Function proxy URLs used by backend/proxy-capable flows. val TMDB_PROXY_URL: String get() = "$NETLIFY_BACKEND_URL/tmdb-proxy" + val SIMKL_PROXY_URL: String get() = "$NETLIFY_BACKEND_URL/simkl-proxy" val TV_AUTH_START_URL: String get() = "$NETLIFY_BACKEND_URL/tv-auth-start" val TV_AUTH_STATUS_URL: String get() = "$NETLIFY_BACKEND_URL/tv-auth-status" val TV_AUTH_POLL_URL: String get() = "$NETLIFY_BACKEND_URL/tv-auth-poll"