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
35 changes: 17 additions & 18 deletions web/app/api/tmdb/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ export async function GET(request: NextRequest, context: { params: Promise<{ pat
"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 tmdbKey = process.env.TMDB_API_KEY ?? "";
const input = new URL(request.url);
const customKey = (request.headers.get("x-tmdb-api-key") ?? "").trim();
const tmdbKey = customKey || process.env.TMDB_API_KEY || "";
input.searchParams.delete("api_key");

let target: URL;
const usesNetlifyProxy = netlifyBackendUrl.startsWith("https://") && appAnonKey.length > 40;
const usesNetlifyProxy = !customKey && netlifyBackendUrl.startsWith("https://") && appAnonKey.length > 40;
if (usesNetlifyProxy) {
target = new URL(`${netlifyBackendUrl}/tmdb-proxy`);
target.searchParams.set("path", `/${path.join("/")}`);
Expand All @@ -41,7 +43,7 @@ export async function GET(request: NextRequest, context: { params: Promise<{ pat
Authorization: `Bearer ${appAnonKey}`
}
: undefined,
next: { revalidate: 86400 },
next: { revalidate: customKey ? 0 : 86400 },
signal: AbortSignal.timeout(8000)
});
} catch {
Expand All @@ -53,7 +55,7 @@ export async function GET(request: NextRequest, context: { params: Promise<{ pat
input.searchParams.forEach((value, key) => direct.searchParams.set(key, value));
direct.searchParams.set("api_key", tmdbKey);
try {
response = await fetch(direct, { next: { revalidate: 86400 }, signal: AbortSignal.timeout(10000) });
response = await fetch(direct, { next: { revalidate: customKey ? 0 : 86400 }, signal: AbortSignal.timeout(10000) });
} catch {
response = null;
}
Expand All @@ -65,20 +67,17 @@ export async function GET(request: NextRequest, context: { params: Promise<{ pat

const headers = new Headers();
headers.set("content-type", response.headers.get("content-type") ?? "application/json");
// Short browser cache; long CDN cache. `durable` opts this response into
// Netlify's global cross-region cache so one upstream fetch serves users
// worldwide (not just per-edge-PoP) — the key scale lever. Without durable,
// a cold PoP re-invokes the function; with it, function hits stay flat as the
// user base grows.
headers.set("cache-control", "public, max-age=300, s-maxage=21600, stale-while-revalidate=21600");
headers.set("netlify-cdn-cache-control", "public, durable, max-age=21600, stale-while-revalidate=21600");
headers.set("cdn-cache-control", "public, max-age=21600, stale-while-revalidate=21600");
// CRITICAL: cache key must vary by query string (else every discover/search
// variant collapses into one entry) but MUST NOT vary by the request-specific
// headers Next.js injects (x-nextjs-data|rsc|…) — those forced the durable
// cache to bypass. Setting query-only Vary is what lets durable engage.
headers.set("netlify-vary", "query");
headers.set("vary", "Accept-Encoding");

if (customKey) {
headers.set("cache-control", "private, no-store");
} else {
// Short browser cache; long CDN cache for shared system key requests.
headers.set("cache-control", "public, max-age=300, s-maxage=21600, stale-while-revalidate=21600");
headers.set("netlify-cdn-cache-control", "public, durable, max-age=21600, stale-while-revalidate=21600");
headers.set("cdn-cache-control", "public, max-age=21600, stale-while-revalidate=21600");
headers.set("netlify-vary", "query");
headers.set("vary", "Accept-Encoding");
}

return new NextResponse(response.body, {
status: response.status,
Expand Down
21 changes: 14 additions & 7 deletions web/components/details/DetailsDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { cachedDebridDirectUrl, isUncachedDebridStream, parseDebridStream, prefe
import { canonicalServiceName, IMDB_LOGO, serviceClearLogo } from "@/lib/serviceLogos";
import { getImdbRating } from "@/lib/imdbRatings";
import { sourcePickerScore } from "@/lib/sourceRank";
import { authClient, useApp } from "@/lib/store";
import { authClient, getPriorityConfig, useApp } from "@/lib/store";
import { syncClient } from "@/lib/sync";
import { getDetails, getLogoUrl, getPersonDetails, getReviews, getSeasonEpisodes } from "@/lib/tmdb";
import type { EpisodeInfo, InstalledAddon, MediaItem, PersonCredit, PersonDetails, ReviewInfo, StreamSource, SubtitleTrack } from "@/lib/types";
Expand Down Expand Up @@ -46,6 +46,7 @@ function DetailsView({ item }: { item: MediaItem }) {
const [sourcePickerVisible, setSourcePickerVisible] = useState(false);
const [logo, setLogo] = useState<string | null>(null);
const displayItem = detailsItem ?? item;
const priorityConfig = useMemo(() => getPriorityConfig(settings), [settings]);

useEffect(() => {
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
Expand All @@ -67,7 +68,7 @@ function DetailsView({ item }: { item: MediaItem }) {
: Boolean(details.cast?.length || details.related?.length || details.trailerUrl);
void (async () => {
for (let attempt = 0; attempt < 3 && active; attempt += 1) {
const details = await getDetails(item).catch(() => null);
const details = await getDetails(item, priorityConfig).catch(() => null);
if (!active) return;
if (details) setDetailsItem(details);
if (details && looksHydrated(details)) break;
Expand All @@ -76,7 +77,7 @@ function DetailsView({ item }: { item: MediaItem }) {
if (active) setDetailsLoading(false);
})();
return () => { active = false; };
}, [item.id, item.mediaType]);
}, [item, priorityConfig]);

useEffect(() => {
let active = true;
Expand Down Expand Up @@ -871,12 +872,18 @@ function SeasonEpisodes({ item, loadingDetails, selectedEpisode, isWatched, onPl
isWatched: (item: MediaItem, seasonNumber?: number | null, episodeNumber?: number | null) => boolean;
onPlayEpisode: (season: number, episode: number) => void;
}) {
const { openContextMenu, setToast, toggleWatched } = useApp();
const { openContextMenu, setToast, settings, toggleWatched } = useApp();
const seasons = item.seasons ?? [];
const [season, setSeason] = useState(seasons[0]?.seasonNumber ?? 1);
const [episodes, setEpisodes] = useState<EpisodeInfo[]>([]);
const [loading, setLoading] = useState(false);
const [retryNonce, setRetryNonce] = useState(0);
const priorityConfig = useMemo(() => getPriorityConfig(settings), [settings]);
const metadataContext = useMemo(() => ({
tvdbId: item.tvdbId,
anilistId: item.anilistId,
isAnime: item.isAnime
}), [item.anilistId, item.isAnime, item.tvdbId]);

useEffect(() => {
if (seasons.length && !seasons.some((entry) => entry.seasonNumber === season)) {
Expand All @@ -887,16 +894,16 @@ function SeasonEpisodes({ item, loadingDetails, selectedEpisode, isWatched, onPl
useEffect(() => {
let active = true;
setLoading(true);
void getSeasonEpisodes(item.id, season)
void getSeasonEpisodes(item.id, season, "en-US", priorityConfig, metadataContext)
.then((eps) => { if (active) setEpisodes(eps); })
.catch(() => undefined)
.finally(() => active && setLoading(false));
return () => { active = false; };
}, [item.id, season, retryNonce]);
}, [item.id, metadataContext, priorityConfig, retryNonce, season]);

const updateSeasonWatched = async (seasonNum: number, watched: boolean) => {
try {
const targetEpisodes = await getSeasonEpisodes(item.id, seasonNum);
const targetEpisodes = await getSeasonEpisodes(item.id, seasonNum, "en-US", priorityConfig, metadataContext);
for (const ep of targetEpisodes) {
if (isWatched(item, seasonNum, ep.episodeNumber) !== watched) {
await toggleWatched(item, seasonNum, ep.episodeNumber);
Expand Down
68 changes: 68 additions & 0 deletions web/components/settings/SettingsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,10 @@ const SECTIONS = [
{ id: "telegram", label: "Telegram", icon: Send },
{ id: "catalogs", label: "Catalogs", icon: ListVideo },
{ id: "addons", label: "Addons", icon: Sparkles },
{ id: "metadata", label: "Metadata & Keys", icon: Sparkles },
] as const;


type SectionId = (typeof SECTIONS)[number]["id"];

const SUBTITLE_COLOR_HEX: Record<AppSettings["subtitleColorName"], string> = {
Expand Down Expand Up @@ -1125,11 +1127,77 @@ function SectionBody({ section }: { section: SectionId }) {
return <AddonsSection />;
case "vlc":
return <VlcSection />;
case "metadata":
return <MetadataSection settings={settings} set={set} />;
Comment on lines +1130 to +1131

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wire metadata settings into the web fetch paths

In the web app context I checked, adding this visible section only persists values: MetadataDispatcher is not imported anywhere, and existing search/details/catalog flows still call web/lib/tmdb.ts directly, so none of customTmdbApiKey, customTvdbApiKey, or the provider-order fields affect requests. Users can enter keys and see “TVDB enabled”, but metadata remains on the previous TMDB path until this section is wired into the fetchers.

Useful? React with 👍 / 👎.

default:
return null;
}
}

function MetadataSection({ settings, set }: { settings: AppSettings; set: (patch: Partial<AppSettings>) => void }) {
const animeChain = (settings.metadataAnimeProviders || ["anilist", "tvdb", "tmdb"]).join(" → ").toUpperCase();
const tvChain = (settings.metadataTvProviders || ["tvdb", "tmdb"]).join(" → ").toUpperCase();
const movieChain = (settings.metadataMovieProviders || ["tmdb"]).join(" → ").toUpperCase();
const tvdbActive = Boolean(settings.customTvdbApiKey?.trim());

return (
<div className="settings-section">
<Panel title="Custom API Keys (Bring Your Own Key)">
<Row label="TMDB API Key" hint="Custom v3 API key for TMDB requests">
<input
type="password"
autoComplete="off"
className="settings-input"
placeholder="System Default Key"
value={settings.customTmdbApiKey || ""}
onChange={(e) => set({ customTmdbApiKey: e.target.value })}
/>
</Row>

<Row
label="TVDB v4 API Key"
hint={tvdbActive ? "Active — TVDB enabled for metadata fallback" : "TVDB is disabled until a custom API key is provided"}
>
<input
type="password"
autoComplete="off"
className="settings-input"
placeholder="Enter Custom TVDB Key to Enable"
value={settings.customTvdbApiKey || ""}
onChange={(e) => set({ customTvdbApiKey: e.target.value })}
/>
</Row>

<Row label="TVDB User PIN" hint="Required if using subscriber user key">
<input
type="password"
autoComplete="off"
className="settings-input"
placeholder="Optional User PIN"
value={settings.customTvdbUserPin || ""}
onChange={(e) => set({ customTvdbUserPin: e.target.value })}
/>
</Row>
</Panel>

<Panel title="Metadata Provider Priorities">
<Row label="Anime Metadata Priority" hint={`Active chain: ${animeChain}`}>
<span className="accent-badge">{animeChain}</span>
</Row>

<Row label="TV Shows Metadata Priority" hint={`Active chain: ${tvChain}`}>
<span className="accent-badge">{tvChain}</span>
</Row>

<Row label="Movies Metadata Priority" hint={`Active chain: ${movieChain}`}>
<span className="accent-badge">{movieChain}</span>
</Row>
</Panel>
</div>
);
}


function safeArray<T>(value: T[] | null | undefined): T[] {
return Array.isArray(value) ? value : [];
}
Expand Down
6 changes: 5 additions & 1 deletion web/lib/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,9 @@ export async function pullCloudPayload(auth: AuthClient, profileId?: string | nu
const hiddenCatalogIds = scopedValue<string[]>(root, "hiddenPreinstalledByProfile", profileId);
const profileAddons = scopedValue<InstalledAddon[]>(root, "addonsByProfile", profileId);
const legacySettings = objectRecord<unknown>(root.settings) as Partial<AppSettings>;
delete legacySettings.customTmdbApiKey;
delete legacySettings.customTvdbApiKey;
delete legacySettings.customTvdbUserPin;
const legacyCatalogs = arrayValue(root.catalogs) as AppSettings["catalogs"];
const legacyHiddenCatalogIds = arrayValue<string>(root.hiddenPreinstalledCatalogs);
// Canonical, timestamp-managed GLOBAL settings live at the top level of the payload (written by
Expand Down Expand Up @@ -752,7 +755,8 @@ export async function saveCloudSettings(
// exclusively through saveCloudAddons (merge-protected). A stale session's
// partial in-memory addon list must never leak into the shared payload.
void addons;
root.settings = settings;
const { customTmdbApiKey: _k1, customTvdbApiKey: _k2, customTvdbUserPin: _k3, ...sanitizedSettings } = settings;
root.settings = sanitizedSettings;

// ── Genuine global settings that Android merges by per-field timestamp. Only write + bump the
// timestamp when the web actually changed the field vs its baseline; otherwise leave the
Expand Down
135 changes: 135 additions & 0 deletions web/lib/metadata/anilist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import type { MediaItem } from "../types";
import type { MetadataMediaType, MetadataResolver } from "./types";

const ANILIST_GRAPHQL_ENDPOINT = "https://graphql.anilist.co";

const MEDIA_QUERY = `
query ($id: Int, $search: String) {
Media(id: $id, search: $search, type: ANIME) {
id
idMal
title {
romaji
english
native
}
description
bannerImage
coverImage {
extraLarge
large
medium
color
}
format
status
episodes
duration
averageScore
popularity
genres
season
seasonYear
studios(isMain: true) {
nodes {
id
name
}
}
}
}
`;

const SEARCH_QUERY = `
query ($search: String) {
Page(page: 1, perPage: 20) {
media(search: $search, type: ANIME) {
id
idMal
title {
romaji
english
native
}
description
bannerImage
coverImage {
large
medium
}
averageScore
seasonYear
episodes
}
}
}
`;

function mapAniListToMediaItem(media: any): MediaItem {
const title = media.title?.english || media.title?.romaji || media.title?.native || "Untitled Anime";
const poster = media.coverImage?.extraLarge || media.coverImage?.large || media.coverImage?.medium || null;
const rating = media.averageScore ? (media.averageScore / 10).toFixed(1) : undefined;

return {
id: media.id,
anilistId: media.id,
title,
subtitle: media.title?.romaji !== title ? media.title?.romaji : undefined,
overview: media.description ? media.description.replace(/<[^>]*>?/gm, "") : "",
year: media.seasonYear ? String(media.seasonYear) : undefined,
rating,
duration: media.duration ? `${media.duration}m` : undefined,
mediaType: "tv",
isAnime: true,
image: poster ?? undefined,
backdrop: media.bannerImage ?? poster ?? null,
badge: media.format ?? "ANIME",
genres: media.genres ?? [],
status: media.status,
numberOfEpisodes: media.episodes ?? null
};
}

export const aniListResolver: MetadataResolver = {
id: "anilist",
name: "AniList",
supportedTypes: ["anime"],

async getDetails(id: string | number, _mediaType?: MetadataMediaType): Promise<MediaItem | null> {
try {
const isNumeric = !isNaN(Number(id));
const variables = isNumeric ? { id: Number(id) } : { search: String(id) };

const res = await fetch(ANILIST_GRAPHQL_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ query: MEDIA_QUERY, variables })
});

if (!res.ok) return null;
const json = await res.json();
if (!json.data?.Media) return null;

return mapAniListToMediaItem(json.data.Media);
} catch {
return null;
}
},

async search(query: string, _mediaType?: MetadataMediaType): Promise<MediaItem[]> {
try {
const res = await fetch(ANILIST_GRAPHQL_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ query: SEARCH_QUERY, variables: { search: query } })
});

if (!res.ok) return [];
const json = await res.json();
const items = json.data?.Page?.media ?? [];
return items.map(mapAniListToMediaItem);
} catch {
return [];
}
}
};
Loading
Loading