Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions web/app/api/simkl/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -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;
10 changes: 5 additions & 5 deletions web/components/details/DetailsDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ function needsDetailsHydration(item: MediaItem) {
}

function DetailsView({ item }: { item: MediaItem }) {
const { streams, selectedEpisode, activeProfile, addons: installedAddons, loadEpisodeStreams, openDetails, playStream, playTrailer, setToast, settings, watchlist, refreshData, busy, isWatched, markWatchedLocally, openContextMenu, toggleWatched } = useApp();
const { streams, selectedEpisode, activeProfile, addons: installedAddons, loadEpisodeStreams, openDetails, playStream, playTrailer, setToast, settings, watchlist, refreshData, busy, isWatched, markWatchedLocally, openContextMenu, toggleWatchlist, toggleWatched } = useApp();
const [detailsItem, setDetailsItem] = useState<MediaItem>(item);
const [detailsLoading, setDetailsLoading] = useState(false);
const [reviews, setReviews] = useState<ReviewInfo[]>([]);
Expand Down Expand Up @@ -139,7 +139,7 @@ function DetailsView({ item }: { item: MediaItem }) {

const addToWatchlist = async () => {
if (!syncClient().isConnected) {
setToast("Connect Trakt or MDBList in Settings to use Watchlist.");
setToast("Connect Trakt, Simkl, or MDBList in Settings to use Watchlist.");
return;
}
try {
Expand All @@ -153,7 +153,7 @@ function DetailsView({ item }: { item: MediaItem }) {

const removeFromWatchlist = async () => {
if (!syncClient().isConnected) {
setToast("Connect Trakt or MDBList in Settings to remove watchlist items.");
setToast("Connect Trakt, Simkl, or MDBList in Settings to remove watchlist items.");
return;
}
try {
Expand Down Expand Up @@ -262,9 +262,9 @@ function DetailsView({ item }: { item: MediaItem }) {
<Play size={18} fill="currentColor" /> {continueLabel}
</button>
{inWatchlist ? (
<button type="button" className="secondary text-button" onClick={() => void removeFromWatchlist()}><Trash2 size={18} /> Remove</button>
<button type="button" className="secondary text-button" onClick={() => void toggleWatchlist(displayItem)}><Trash2 size={18} /> Remove</button>
) : (
<button type="button" className="secondary text-button" onClick={() => void addToWatchlist()}><Bookmark size={18} /> Watchlist</button>
<button type="button" className="secondary text-button" onClick={() => void toggleWatchlist(displayItem)}><Bookmark size={18} /> Watchlist</button>
)}
<button type="button" className={`secondary text-button ${detailWatched ? "is-active" : ""}`} onClick={() => void markWatched()}><BadgeCheck size={18} /> {detailWatched ? "Watched" : "Mark Watched"}</button>
{displayItem.trailerUrl && (
Expand Down
102 changes: 102 additions & 0 deletions web/components/settings/SettingsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
hasNetlifyBackendConfig,
hasSupabaseConfig,
hasTraktConfig,
hasSimklConfig,
getAuthPortalUrl,
} from "@/lib/config";
import {
Expand Down Expand Up @@ -1152,17 +1153,24 @@ function AccountsSection() {
auth,
traktConnected,
mdblistConnected,
simklConnected,
deviceCode,
simklDeviceCode,
signOut,
beginTrakt,
pollTrakt,
disconnectTrakt,
connectMdblist,
disconnectMdblist,
beginSimkl,
pollSimkl,
disconnectSimkl,
refreshData,
} = useApp();
const [traktError, setTraktError] = useState<string | null>(null);
const [traktBusy, setTraktBusy] = useState<"start" | "poll" | null>(null);
const [simklError, setSimklError] = useState<string | null>(null);
const [simklBusy, setSimklBusy] = useState<"start" | "poll" | null>(null);
const [mdblistKey, setMdblistKey] = useState("");
const [mdblistError, setMdblistError] = useState<string | null>(null);
const [mdblistBusy, setMdblistBusy] = useState(false);
Expand Down Expand Up @@ -1206,6 +1214,38 @@ function AccountsSection() {
}
};

const startSimklLink = async () => {
setSimklBusy("start");
setSimklError(null);
try {
await beginSimkl();
} catch (error) {
setSimklError(
error instanceof Error
? error.message
: "Could not start Simkl device link.",
);
} finally {
setSimklBusy(null);
}
};

const approveSimklLink = async () => {
setSimklBusy("poll");
setSimklError(null);
try {
await pollSimkl();
} catch (error) {
setSimklError(
error instanceof Error
? error.message
: "Simkl has not approved this device yet.",
);
} finally {
setSimklBusy(null);
}
};

const connectMdblistLink = async () => {
setMdblistBusy(true);
setMdblistError(null);
Expand Down Expand Up @@ -1260,6 +1300,20 @@ function AccountsSection() {
: "Missing config"}
</strong>
</div>
<div>
<span>Simkl</span>
<strong>
{simklConnected
? "Connected"
: hasSimklConfig()
? "Not linked"
: "Missing config"}
</strong>
</div>
<div>
<span>MDBList</span>
<strong>{mdblistConnected ? "Connected" : "Not linked"}</strong>
</div>
<div>
<span>Sync</span>
<strong>{auth ? "Cloud saved" : "Local only"}</strong>
Expand Down Expand Up @@ -1327,6 +1381,54 @@ function AccountsSection() {
)}
</Panel>

<Panel title="Simkl">
{!hasSimklConfig() && (
<p className="empty">Simkl client configuration is missing.</p>
)}
{simklError && <p className="login-error">{simklError}</p>}
{simklConnected ? (
<button type="button" className="secondary" onClick={disconnectSimkl}>
Disconnect Simkl
</button>
) : (
<>
<button
type="button"
className="primary"
disabled={simklBusy === "start" || !hasSimklConfig()}
onClick={() => void startSimklLink()}
>
{simklBusy === "start" ? "Starting..." : "Start device link"}
</button>
{simklDeviceCode && (
<div className="device-code">
<span>{simklDeviceCode.user_code}</span>
<p>
Open{" "}
<a
href={simklDeviceCode.verification_url || "https://simkl.com/pin"}
target="_blank"
rel="noreferrer"
style={{ color: "var(--accent)", textDecoration: "underline" }}
>
{simklDeviceCode.verification_url || "https://simkl.com/pin"}
</a>{" "}
and enter the code above
</p>
<button
type="button"
className="secondary"
disabled={simklBusy === "poll"}
onClick={() => void approveSimklLink()}
>
{simklBusy === "poll" ? "Checking..." : "I approved it"}
</button>
</div>
)}
</>
)}
</Panel>

<Panel title="MDBList">
{mdblistError && <p className="login-error">{mdblistError}</p>}
{mdblistConnected ? (
Expand Down
5 changes: 3 additions & 2 deletions web/components/shell/SyncStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import { Cloud } from "lucide-react";
import { useApp } from "@/lib/store";

export function SyncStrip() {
const { busy, auth, traktConnected } = useApp();
const { busy, auth, traktConnected, simklConnected, mdblistConnected } = useApp();
const syncLabel = traktConnected ? "Trakt On" : simklConnected ? "Simkl On" : mdblistConnected ? "MDBList On" : "Sync Off";
return (
<div className="sync-strip" aria-hidden={!busy}>
<Cloud size={16} />
<span>{busy || (auth ? "Cloud online" : "Cloud offline")}</span>
<span>{traktConnected ? "Trakt On" : "Trakt Off"}</span>
<span>{syncLabel}</span>
</div>
);
}
12 changes: 10 additions & 2 deletions web/components/watchlist/WatchlistScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const BUILTIN_SOURCES = [
] as const;

export function WatchlistScreen() {
const { watchlist, traktConnected, openDetails, settings, loadTraktLists, loadTraktListItems } = useApp();
const { watchlist, traktConnected, simklConnected, mdblistConnected, openDetails, settings, loadTraktLists, loadTraktListItems } = useApp();
const [sort, setSort] = useState<WatchlistSort>("added");
const [filter, setFilter] = useState<WatchlistFilter>("all");
const [source, setSource] = useState("watchlist");
Expand Down Expand Up @@ -87,7 +87,15 @@ export function WatchlistScreen() {
<div className={`screen ${posterMode ? "poster-results" : ""}`}>
<section className="section-heading watchlist-heading">
<div>
<p className="eyebrow">{traktConnected ? "Synced with your Trakt account" : "Connect Trakt in Settings to sync"}</p>
<p className="eyebrow">
{traktConnected
? "Synced with your Trakt account"
: simklConnected
? "Synced with your Simkl account"
: mdblistConnected
? "Synced with your MDBList account"
: "Connect Trakt, Simkl, or MDBList in Settings to sync"}
</p>
<h2>Watchlist</h2>
</div>
<div className="watchlist-toolbar">
Expand Down
6 changes: 6 additions & 0 deletions web/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export const config = {
resolverUrl: envValue(process.env.NEXT_PUBLIC_ARVIO_RESOLVER_URL, ""),
traktClientId: process.env.NEXT_PUBLIC_TRAKT_CLIENT_ID ?? "",
traktClientSecret: envValue(process.env.NEXT_PUBLIC_TRAKT_CLIENT_SECRET, ""),
simklClientId: process.env.NEXT_PUBLIC_SIMKL_CLIENT_ID ?? process.env.SIMKL_CLIENT_ID ?? "",
simklClientSecret: envValue(process.env.NEXT_PUBLIC_SIMKL_CLIENT_SECRET, process.env.SIMKL_CLIENT_SECRET ?? ""),
allowNetlifyMediaProxy: envValue(process.env.NEXT_PUBLIC_ALLOW_NETLIFY_MEDIA_PROXY, "false") === "true",
// Web subscription: the Ko-fi membership page the paywall links to, and a
// master switch to enable the paywall (off by default so nothing changes for
Expand Down Expand Up @@ -37,6 +39,10 @@ export function hasTraktConfig() {
return config.traktClientId.length > 10 && !config.traktClientId.startsWith("__");
}

export function hasSimklConfig() {
return hasNetlifyBackendConfig() || (config.simklClientId.length > 10 && !config.simklClientId.startsWith("__"));
}

export function getAuthPortalUrl(): string {
const backend = config.netlifyBackendUrl;
try {
Expand Down
Loading
Loading