Merge pull request #7 from ksyasuda/feature/add-anilist-tracking

Add AniList Tracking
This commit is contained in:
2026-02-17 00:08:33 -08:00
committed by GitHub
19 changed files with 1642 additions and 19 deletions

View File

@@ -132,6 +132,13 @@ import {
triggerFieldGroupingService,
updateLastCardFromClipboardService,
} from "./core/services";
import {
guessAnilistMediaInfo,
type AnilistMediaGuess,
updateAnilistPostWatchProgress,
} from "./core/services/anilist/anilist-updater";
import { createAnilistTokenStore } from "./core/services/anilist/anilist-token-store";
import { createAnilistUpdateQueue } from "./core/services/anilist/anilist-update-queue";
import { applyRuntimeOptionResultRuntimeService } from "./core/services/runtime-options-ipc-service";
import {
createAppReadyRuntimeRunner,
@@ -169,9 +176,14 @@ import {
import { createMediaRuntimeService } from "./main/media-runtime";
import { createOverlayVisibilityRuntimeService } from "./main/overlay-visibility-runtime";
import {
type AppState,
applyStartupState,
createAppState,
} from "./main/state";
import {
isAllowedAnilistExternalUrl,
isAllowedAnilistSetupNavigationUrl,
} from "./main/anilist-url-guard";
import { createStartupBootstrapRuntimeDeps } from "./main/startup";
import { createAppLifecycleRuntimeRunner } from "./main/startup-lifecycle";
import {
@@ -192,6 +204,27 @@ const DEFAULT_MPV_LOG_FILE = path.join(
"SubMiner",
"mp.log",
);
const ANILIST_SETUP_CLIENT_ID_URL = "https://anilist.co/api/v2/oauth/authorize";
const ANILIST_SETUP_RESPONSE_TYPE = "token";
const ANILIST_DEFAULT_CLIENT_ID = "36084";
const ANILIST_REDIRECT_URI = "https://anilist.subminer.moe/";
const ANILIST_DEVELOPER_SETTINGS_URL = "https://anilist.co/settings/developer";
const ANILIST_UPDATE_MIN_WATCH_RATIO = 0.85;
const ANILIST_UPDATE_MIN_WATCH_SECONDS = 10 * 60;
const ANILIST_DURATION_RETRY_INTERVAL_MS = 15_000;
const ANILIST_MAX_ATTEMPTED_UPDATE_KEYS = 1000;
const ANILIST_TOKEN_STORE_FILE = "anilist-token-store.json";
const ANILIST_RETRY_QUEUE_FILE = "anilist-retry-queue.json";
let anilistCurrentMediaKey: string | null = null;
let anilistCurrentMediaDurationSec: number | null = null;
let anilistCurrentMediaGuess: AnilistMediaGuess | null = null;
let anilistCurrentMediaGuessPromise: Promise<AnilistMediaGuess | null> | null = null;
let anilistLastDurationProbeAtMs = 0;
let anilistUpdateInFlight = false;
const anilistAttemptedUpdateKeys = new Set<string>();
let anilistCachedAccessToken: string | null = null;
function resolveConfigDir(): string {
const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim();
const baseDirs = Array.from(
@@ -230,6 +263,22 @@ const CONFIG_DIR = resolveConfigDir();
const USER_DATA_PATH = CONFIG_DIR;
const DEFAULT_MPV_LOG_PATH = process.env.SUBMINER_MPV_LOG?.trim() || DEFAULT_MPV_LOG_FILE;
const configService = new ConfigService(CONFIG_DIR);
const anilistTokenStore = createAnilistTokenStore(
path.join(USER_DATA_PATH, ANILIST_TOKEN_STORE_FILE),
{
info: (message: string) => console.info(message),
warn: (message: string, details?: unknown) => console.warn(message, details),
error: (message: string, details?: unknown) => console.error(message, details),
},
);
const anilistUpdateQueue = createAnilistUpdateQueue(
path.join(USER_DATA_PATH, ANILIST_RETRY_QUEUE_FILE),
{
info: (message: string) => console.info(message),
warn: (message: string, details?: unknown) => console.warn(message, details),
error: (message: string, details?: unknown) => console.error(message, details),
},
);
const isDev =
process.argv.includes("--dev") || process.argv.includes("--debug");
const texthookerService = new TexthookerService();
@@ -572,6 +621,436 @@ async function jimakuFetchJson<T>(
});
}
function setAnilistClientSecretState(partial: Partial<AppState["anilistClientSecretState"]>): void {
appState.anilistClientSecretState = {
...appState.anilistClientSecretState,
...partial,
};
}
function refreshAnilistRetryQueueState(): void {
appState.anilistRetryQueueState = {
...appState.anilistRetryQueueState,
...anilistUpdateQueue.getSnapshot(),
};
}
function isAnilistTrackingEnabled(resolved: ResolvedConfig): boolean {
return resolved.anilist.enabled;
}
function buildAnilistSetupUrl(): string {
const authorizeUrl = new URL(ANILIST_SETUP_CLIENT_ID_URL);
authorizeUrl.searchParams.set("client_id", ANILIST_DEFAULT_CLIENT_ID);
authorizeUrl.searchParams.set("response_type", ANILIST_SETUP_RESPONSE_TYPE);
authorizeUrl.searchParams.set("redirect_uri", ANILIST_REDIRECT_URI);
return authorizeUrl.toString();
}
function openAnilistSetupInBrowser(): void {
const authorizeUrl = buildAnilistSetupUrl();
void shell.openExternal(authorizeUrl).catch((error) => {
logger.error("Failed to open AniList authorize URL in browser", error);
});
}
function loadAnilistSetupFallback(setupWindow: BrowserWindow, reason: string): void {
const authorizeUrl = buildAnilistSetupUrl();
const fallbackHtml = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>AniList Setup</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; padding: 24px; background: #0b1020; color: #e5e7eb; }
h1 { margin: 0 0 12px; font-size: 22px; }
p { margin: 10px 0; line-height: 1.45; color: #cbd5e1; }
a { color: #93c5fd; word-break: break-all; }
.box { background: #111827; border: 1px solid #1f2937; border-radius: 10px; padding: 16px; }
.reason { color: #fca5a5; }
</style>
</head>
<body>
<h1>AniList Setup</h1>
<div class="box">
<p class="reason">Embedded AniList page did not render: ${reason}</p>
<p>We attempted to open the authorize URL in your default browser automatically.</p>
<p>Use one of these links to continue setup:</p>
<p><a href="${authorizeUrl}">${authorizeUrl}</a></p>
<p><a href="${ANILIST_DEVELOPER_SETTINGS_URL}">${ANILIST_DEVELOPER_SETTINGS_URL}</a></p>
<p>After login/authorization, copy the token into <code>anilist.accessToken</code>.</p>
</div>
</body>
</html>`;
void setupWindow.loadURL(
`data:text/html;charset=utf-8,${encodeURIComponent(fallbackHtml)}`,
);
}
function openAnilistSetupWindow(): void {
if (appState.anilistSetupWindow) {
appState.anilistSetupWindow.focus();
return;
}
const setupWindow = new BrowserWindow({
width: 1000,
height: 760,
title: "Anilist Setup",
show: true,
autoHideMenuBar: true,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
});
setupWindow.webContents.setWindowOpenHandler(({ url }) => {
if (!isAllowedAnilistExternalUrl(url)) {
logger.warn("Blocked unsafe AniList setup external URL", { url });
return { action: "deny" };
}
void shell.openExternal(url);
return { action: "deny" };
});
setupWindow.webContents.on("will-navigate", (event, url) => {
if (isAllowedAnilistSetupNavigationUrl(url)) {
return;
}
event.preventDefault();
logger.warn("Blocked unsafe AniList setup navigation URL", { url });
});
setupWindow.webContents.on(
"did-fail-load",
(_event, errorCode, errorDescription, validatedURL) => {
logger.error("AniList setup window failed to load", {
errorCode,
errorDescription,
validatedURL,
});
openAnilistSetupInBrowser();
if (!setupWindow.isDestroyed()) {
loadAnilistSetupFallback(
setupWindow,
`${errorDescription} (${errorCode})`,
);
}
},
);
setupWindow.webContents.on("did-finish-load", () => {
const loadedUrl = setupWindow.webContents.getURL();
if (!loadedUrl || loadedUrl === "about:blank") {
logger.warn("AniList setup loaded a blank page; using fallback");
openAnilistSetupInBrowser();
if (!setupWindow.isDestroyed()) {
loadAnilistSetupFallback(setupWindow, "blank page");
}
}
});
void setupWindow.loadURL(buildAnilistSetupUrl()).catch((error) => {
logger.error("AniList setup loadURL rejected", error);
openAnilistSetupInBrowser();
if (!setupWindow.isDestroyed()) {
loadAnilistSetupFallback(
setupWindow,
error instanceof Error ? error.message : String(error),
);
}
});
setupWindow.on("closed", () => {
appState.anilistSetupWindow = null;
appState.anilistSetupPageOpened = false;
});
appState.anilistSetupWindow = setupWindow;
appState.anilistSetupPageOpened = true;
}
async function refreshAnilistClientSecretState(options?: { force?: boolean }): Promise<string | null> {
const resolved = getResolvedConfig();
const now = Date.now();
if (!isAnilistTrackingEnabled(resolved)) {
anilistCachedAccessToken = null;
setAnilistClientSecretState({
status: "not_checked",
source: "none",
message: "anilist tracking disabled",
resolvedAt: null,
errorAt: null,
});
appState.anilistSetupPageOpened = false;
return null;
}
const rawAccessToken = resolved.anilist.accessToken.trim();
if (rawAccessToken.length > 0) {
if (options?.force || rawAccessToken !== anilistCachedAccessToken) {
anilistTokenStore.saveToken(rawAccessToken);
}
anilistCachedAccessToken = rawAccessToken;
setAnilistClientSecretState({
status: "resolved",
source: "literal",
message: "using configured anilist.accessToken",
resolvedAt: now,
errorAt: null,
});
appState.anilistSetupPageOpened = false;
return rawAccessToken;
}
if (!options?.force && anilistCachedAccessToken && anilistCachedAccessToken.length > 0) {
return anilistCachedAccessToken;
}
const storedToken = anilistTokenStore.loadToken()?.trim() ?? "";
if (storedToken.length > 0) {
anilistCachedAccessToken = storedToken;
setAnilistClientSecretState({
status: "resolved",
source: "stored",
message: "using stored anilist access token",
resolvedAt: now,
errorAt: null,
});
appState.anilistSetupPageOpened = false;
return storedToken;
}
anilistCachedAccessToken = null;
setAnilistClientSecretState({
status: "error",
source: "none",
message: "cannot authenticate without anilist.accessToken",
resolvedAt: null,
errorAt: now,
});
if (
isAnilistTrackingEnabled(resolved) &&
!appState.anilistSetupPageOpened
) {
openAnilistSetupWindow();
}
return null;
}
function getCurrentAnilistMediaKey(): string | null {
const path = appState.currentMediaPath?.trim();
return path && path.length > 0 ? path : null;
}
function resetAnilistMediaTracking(mediaKey: string | null): void {
anilistCurrentMediaKey = mediaKey;
anilistCurrentMediaDurationSec = null;
anilistCurrentMediaGuess = null;
anilistCurrentMediaGuessPromise = null;
anilistLastDurationProbeAtMs = 0;
}
async function maybeProbeAnilistDuration(mediaKey: string): Promise<number | null> {
if (anilistCurrentMediaKey !== mediaKey) {
return null;
}
if (
typeof anilistCurrentMediaDurationSec === "number" &&
anilistCurrentMediaDurationSec > 0
) {
return anilistCurrentMediaDurationSec;
}
const now = Date.now();
if (now - anilistLastDurationProbeAtMs < ANILIST_DURATION_RETRY_INTERVAL_MS) {
return null;
}
anilistLastDurationProbeAtMs = now;
try {
const durationCandidate = await appState.mpvClient?.requestProperty("duration");
const duration =
typeof durationCandidate === "number" && Number.isFinite(durationCandidate)
? durationCandidate
: null;
if (duration && duration > 0 && anilistCurrentMediaKey === mediaKey) {
anilistCurrentMediaDurationSec = duration;
return duration;
}
} catch (error) {
logger.warn("AniList duration probe failed:", error);
}
return null;
}
async function ensureAnilistMediaGuess(mediaKey: string): Promise<AnilistMediaGuess | null> {
if (anilistCurrentMediaKey !== mediaKey) {
return null;
}
if (anilistCurrentMediaGuess) {
return anilistCurrentMediaGuess;
}
if (anilistCurrentMediaGuessPromise) {
return anilistCurrentMediaGuessPromise;
}
const mediaPathForGuess = mediaRuntime.resolveMediaPathForJimaku(
appState.currentMediaPath,
);
anilistCurrentMediaGuessPromise = guessAnilistMediaInfo(
mediaPathForGuess,
appState.currentMediaTitle,
)
.then((guess) => {
if (anilistCurrentMediaKey === mediaKey) {
anilistCurrentMediaGuess = guess;
}
return guess;
})
.finally(() => {
if (anilistCurrentMediaKey === mediaKey) {
anilistCurrentMediaGuessPromise = null;
}
});
return anilistCurrentMediaGuessPromise;
}
function buildAnilistAttemptKey(mediaKey: string, episode: number): string {
return `${mediaKey}::${episode}`;
}
function rememberAnilistAttemptedUpdateKey(key: string): void {
anilistAttemptedUpdateKeys.add(key);
if (anilistAttemptedUpdateKeys.size <= ANILIST_MAX_ATTEMPTED_UPDATE_KEYS) {
return;
}
const oldestKey = anilistAttemptedUpdateKeys.values().next().value;
if (typeof oldestKey === "string") {
anilistAttemptedUpdateKeys.delete(oldestKey);
}
}
async function processNextAnilistRetryUpdate(): Promise<void> {
const queued = anilistUpdateQueue.nextReady();
refreshAnilistRetryQueueState();
if (!queued) {
return;
}
appState.anilistRetryQueueState.lastAttemptAt = Date.now();
const accessToken = await refreshAnilistClientSecretState();
if (!accessToken) {
appState.anilistRetryQueueState.lastError = "AniList token unavailable for queued retry.";
return;
}
const result = await updateAnilistPostWatchProgress(
accessToken,
queued.title,
queued.episode,
);
if (result.status === "updated" || result.status === "skipped") {
anilistUpdateQueue.markSuccess(queued.key);
rememberAnilistAttemptedUpdateKey(queued.key);
appState.anilistRetryQueueState.lastError = null;
refreshAnilistRetryQueueState();
logger.info(`[AniList queue] ${result.message}`);
return;
}
anilistUpdateQueue.markFailure(queued.key, result.message);
appState.anilistRetryQueueState.lastError = result.message;
refreshAnilistRetryQueueState();
}
async function maybeRunAnilistPostWatchUpdate(): Promise<void> {
if (anilistUpdateInFlight) {
return;
}
const resolved = getResolvedConfig();
if (!isAnilistTrackingEnabled(resolved)) {
return;
}
const mediaKey = getCurrentAnilistMediaKey();
if (!mediaKey || !appState.mpvClient) {
return;
}
if (anilistCurrentMediaKey !== mediaKey) {
resetAnilistMediaTracking(mediaKey);
}
const watchedSeconds = appState.mpvClient.currentTimePos;
if (
!Number.isFinite(watchedSeconds) ||
watchedSeconds < ANILIST_UPDATE_MIN_WATCH_SECONDS
) {
return;
}
const duration = await maybeProbeAnilistDuration(mediaKey);
if (!duration || duration <= 0) {
return;
}
if (watchedSeconds / duration < ANILIST_UPDATE_MIN_WATCH_RATIO) {
return;
}
const guess = await ensureAnilistMediaGuess(mediaKey);
if (!guess?.title || !guess.episode || guess.episode <= 0) {
return;
}
const attemptKey = buildAnilistAttemptKey(mediaKey, guess.episode);
if (anilistAttemptedUpdateKeys.has(attemptKey)) {
return;
}
anilistUpdateInFlight = true;
try {
await processNextAnilistRetryUpdate();
const accessToken = await refreshAnilistClientSecretState();
if (!accessToken) {
anilistUpdateQueue.enqueue(attemptKey, guess.title, guess.episode);
anilistUpdateQueue.markFailure(
attemptKey,
"cannot authenticate without anilist.accessToken",
);
refreshAnilistRetryQueueState();
showMpvOsd("AniList: access token not configured");
return;
}
const result = await updateAnilistPostWatchProgress(
accessToken,
guess.title,
guess.episode,
);
if (result.status === "updated") {
rememberAnilistAttemptedUpdateKey(attemptKey);
anilistUpdateQueue.markSuccess(attemptKey);
refreshAnilistRetryQueueState();
showMpvOsd(result.message);
logger.info(result.message);
return;
}
if (result.status === "skipped") {
rememberAnilistAttemptedUpdateKey(attemptKey);
anilistUpdateQueue.markSuccess(attemptKey);
refreshAnilistRetryQueueState();
logger.info(result.message);
return;
}
anilistUpdateQueue.enqueue(attemptKey, guess.title, guess.episode);
anilistUpdateQueue.markFailure(attemptKey, result.message);
refreshAnilistRetryQueueState();
showMpvOsd(`AniList: ${result.message}`);
logger.warn(result.message);
} finally {
anilistUpdateInFlight = false;
}
}
function loadSubtitlePosition(): SubtitlePosition | null {
appState.subtitlePosition = loadSubtitlePositionService({
currentMediaPath: appState.currentMediaPath,
@@ -655,6 +1134,7 @@ const startupState = runStartupBootstrapRuntimeService(
reloadConfig: () => {
configService.reloadConfig();
appLogger.logInfo(`Using config file: ${configService.getConfigPath()}`);
void refreshAnilistClientSecretState({ force: true });
},
getResolvedConfig: () => getResolvedConfig(),
getConfigWarnings: () => configService.getWarnings(),
@@ -731,6 +1211,10 @@ const startupState = runStartupBootstrapRuntimeService(
if (appState.ankiIntegration) {
appState.ankiIntegration.destroy();
}
if (appState.anilistSetupWindow) {
appState.anilistSetupWindow.destroy();
}
appState.anilistSetupWindow = null;
},
shouldRestoreWindowsOnActivate: () =>
appState.overlayRuntimeInitialized && BrowserWindow.getAllWindows().length === 0,
@@ -745,6 +1229,8 @@ const startupState = runStartupBootstrapRuntimeService(
);
applyStartupState(appState, startupState);
void refreshAnilistClientSecretState({ force: true });
refreshAnilistRetryQueueState();
function handleCliCommand(
args: CliArgs,
@@ -828,16 +1314,26 @@ function bindMpvClientEventHandlers(mpvClient: MpvIpcClient): void {
broadcastToOverlayWindows("secondary-subtitle:set", text);
});
mpvClient.on("subtitle-timing", ({ text, start, end }) => {
if (!text.trim() || !appState.subtitleTimingTracker) {
return;
if (text.trim() && appState.subtitleTimingTracker) {
appState.subtitleTimingTracker.recordSubtitle(text, start, end);
}
appState.subtitleTimingTracker.recordSubtitle(text, start, end);
void maybeRunAnilistPostWatchUpdate().catch((error) => {
logger.error("AniList post-watch update failed unexpectedly", error);
});
});
mpvClient.on("media-path-change", ({ path }) => {
mediaRuntime.updateCurrentMediaPath(path);
const mediaKey = getCurrentAnilistMediaKey();
resetAnilistMediaTracking(mediaKey);
if (mediaKey) {
void maybeProbeAnilistDuration(mediaKey);
void ensureAnilistMediaGuess(mediaKey);
}
});
mpvClient.on("media-title-change", ({ title }) => {
mediaRuntime.updateCurrentMediaTitle(title);
anilistCurrentMediaGuess = null;
anilistCurrentMediaGuessPromise = null;
});
mpvClient.on("subtitle-metrics-change", ({ patch }) => {
updateMpvSubtitleRenderMetrics(patch);