Fix AniList URL guard

This commit is contained in:
2026-02-16 01:56:21 -08:00
parent faf82fa3ed
commit 107971f151
14 changed files with 1010 additions and 18 deletions

View File

@@ -0,0 +1,76 @@
import * as childProcess from "child_process";
const SECRET_COMMAND_PATTERN = /^\((.*)\)$/s;
const COMMAND_CACHE = new Map<string, string>();
const COMMAND_PENDING = new Map<string, Promise<string>>();
function executeCommand(command: string): Promise<string> {
return new Promise((resolve, reject) => {
childProcess.exec(command, { timeout: 10_000 }, (err, stdout) => {
if (err) {
reject(err);
return;
}
resolve(stdout);
});
});
}
export function clearAnilistClientSecretCache(): void {
COMMAND_CACHE.clear();
COMMAND_PENDING.clear();
}
function resolveCommand(rawSecret: string): string | null {
const commandMatch = rawSecret.match(SECRET_COMMAND_PATTERN);
if (!commandMatch || commandMatch[1] === undefined) {
return null;
}
const command = commandMatch[1].trim();
return command.length > 0 ? command : null;
}
export async function resolveAnilistClientSecret(rawSecret: string): Promise<string> {
const trimmedSecret = rawSecret.trim();
if (trimmedSecret.length === 0) {
throw new Error("cannot authenticate without client secret");
}
const command = resolveCommand(trimmedSecret);
if (!command) {
return trimmedSecret;
}
const cachedValue = COMMAND_CACHE.get(trimmedSecret);
if (cachedValue !== undefined) {
return cachedValue;
}
const pending = COMMAND_PENDING.get(trimmedSecret);
if (pending !== undefined) {
return pending;
}
const promise = executeCommand(command)
.then((stdout) => {
const trimmed = stdout.trim();
if (!trimmed) {
throw new Error("secret command returned empty value");
}
COMMAND_CACHE.set(trimmedSecret, trimmed);
COMMAND_PENDING.delete(trimmedSecret);
return trimmed;
})
.catch((error) => {
COMMAND_PENDING.delete(trimmedSecret);
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage === "secret command returned empty value") {
throw error;
}
throw new Error(`secret command failed: ${errorMessage}`);
});
COMMAND_PENDING.set(trimmedSecret, promise);
return promise;
}

View File

@@ -0,0 +1,301 @@
import * as childProcess from "child_process";
import { parseMediaInfo } from "../../../jimaku/utils";
const ANILIST_GRAPHQL_URL = "https://graphql.anilist.co";
export interface AnilistMediaGuess {
title: string;
episode: number | null;
source: "guessit" | "fallback";
}
export interface AnilistPostWatchUpdateResult {
status: "updated" | "skipped" | "error";
message: string;
}
interface AnilistGraphQlError {
message?: string;
}
interface AnilistGraphQlResponse<T> {
data?: T;
errors?: AnilistGraphQlError[];
}
interface AnilistSearchData {
Page?: {
media?: Array<{
id: number;
episodes: number | null;
title?: {
romaji?: string | null;
english?: string | null;
native?: string | null;
};
}>;
};
}
interface AnilistMediaEntryData {
Media?: {
id: number;
mediaListEntry?: {
progress?: number | null;
status?: string | null;
} | null;
} | null;
}
interface AnilistSaveEntryData {
SaveMediaListEntry?: {
progress?: number | null;
status?: string | null;
};
}
function runGuessit(target: string): Promise<string> {
return new Promise((resolve, reject) => {
childProcess.execFile(
"guessit",
[target, "--json"],
{ timeout: 5000, maxBuffer: 1024 * 1024 },
(error, stdout) => {
if (error) {
reject(error);
return;
}
resolve(stdout);
},
);
});
}
function firstString(value: unknown): string | null {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
if (Array.isArray(value)) {
for (const item of value) {
const candidate = firstString(item);
if (candidate) return candidate;
}
}
return null;
}
function firstPositiveInteger(value: unknown): number | null {
if (typeof value === "number" && Number.isInteger(value) && value > 0) {
return value;
}
if (typeof value === "string") {
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
if (Array.isArray(value)) {
for (const item of value) {
const candidate = firstPositiveInteger(item);
if (candidate !== null) return candidate;
}
}
return null;
}
function normalizeTitle(text: string): string {
return text.trim().toLowerCase().replace(/\s+/g, " ");
}
async function anilistGraphQl<T>(
accessToken: string,
query: string,
variables: Record<string, unknown>,
): Promise<AnilistGraphQlResponse<T>> {
try {
const response = await fetch(ANILIST_GRAPHQL_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ query, variables }),
});
const payload = (await response.json()) as AnilistGraphQlResponse<T>;
return payload;
} catch (error) {
return {
errors: [
{
message:
error instanceof Error ? error.message : String(error),
},
],
};
}
}
function firstErrorMessage<T>(response: AnilistGraphQlResponse<T>): string | null {
const firstError = response.errors?.find((item) => Boolean(item?.message));
return firstError?.message ?? null;
}
function pickBestSearchResult(
title: string,
episode: number,
media: Array<{
id: number;
episodes: number | null;
title?: {
romaji?: string | null;
english?: string | null;
native?: string | null;
};
}>,
): { id: number; title: string } | null {
const filtered = media.filter((item) => {
const totalEpisodes = item.episodes;
return totalEpisodes === null || totalEpisodes >= episode;
});
const candidates = filtered.length > 0 ? filtered : media;
if (candidates.length === 0) return null;
const normalizedTarget = normalizeTitle(title);
const exact = candidates.find((item) => {
const titles = [
item.title?.romaji,
item.title?.english,
item.title?.native,
]
.filter((value): value is string => typeof value === "string")
.map((value) => normalizeTitle(value));
return titles.includes(normalizedTarget);
});
const selected = exact ?? candidates[0];
const selectedTitle =
selected.title?.english ||
selected.title?.romaji ||
selected.title?.native ||
title;
return { id: selected.id, title: selectedTitle };
}
export async function guessAnilistMediaInfo(
mediaPath: string | null,
mediaTitle: string | null,
): Promise<AnilistMediaGuess | null> {
const target = mediaPath ?? mediaTitle;
if (target && target.trim().length > 0) {
try {
const stdout = await runGuessit(target);
const parsed = JSON.parse(stdout) as Record<string, unknown>;
const title = firstString(parsed.title);
const episode = firstPositiveInteger(parsed.episode);
if (title) {
return { title, episode, source: "guessit" };
}
} catch {
// Ignore guessit failures and fall back to internal parser.
}
}
const fallbackTarget = mediaPath ?? mediaTitle;
const parsed = parseMediaInfo(fallbackTarget);
if (!parsed.title.trim()) {
return null;
}
return {
title: parsed.title.trim(),
episode: parsed.episode,
source: "fallback",
};
}
export async function updateAnilistPostWatchProgress(
accessToken: string,
title: string,
episode: number,
): Promise<AnilistPostWatchUpdateResult> {
const searchResponse = await anilistGraphQl<AnilistSearchData>(
accessToken,
`
query ($search: String!) {
Page(perPage: 5) {
media(search: $search, type: ANIME) {
id
episodes
title {
romaji
english
native
}
}
}
}
`,
{ search: title },
);
const searchError = firstErrorMessage(searchResponse);
if (searchError) {
return { status: "error", message: `AniList search failed: ${searchError}` };
}
const media = searchResponse.data?.Page?.media ?? [];
const picked = pickBestSearchResult(title, episode, media);
if (!picked) {
return { status: "error", message: "AniList search returned no matches." };
}
const entryResponse = await anilistGraphQl<AnilistMediaEntryData>(
accessToken,
`
query ($mediaId: Int!) {
Media(id: $mediaId, type: ANIME) {
id
mediaListEntry {
progress
status
}
}
}
`,
{ mediaId: picked.id },
);
const entryError = firstErrorMessage(entryResponse);
if (entryError) {
return { status: "error", message: `AniList entry lookup failed: ${entryError}` };
}
const currentProgress = entryResponse.data?.Media?.mediaListEntry?.progress ?? 0;
if (typeof currentProgress === "number" && currentProgress >= episode) {
return {
status: "skipped",
message: `AniList already at episode ${currentProgress} (${picked.title}).`,
};
}
const saveResponse = await anilistGraphQl<AnilistSaveEntryData>(
accessToken,
`
mutation ($mediaId: Int!, $progress: Int!) {
SaveMediaListEntry(mediaId: $mediaId, progress: $progress, status: CURRENT) {
progress
status
}
}
`,
{ mediaId: picked.id, progress: episode },
);
const saveError = firstErrorMessage(saveResponse);
if (saveError) {
return { status: "error", message: `AniList update failed: ${saveError}` };
}
return {
status: "updated",
message: `AniList updated "${picked.title}" to episode ${episode}.`,
};
}