mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
feat(core): add Electron runtime, services, and app composition
This commit is contained in:
85
src/core/services/anilist/anilist-token-store.test.ts
Normal file
85
src/core/services/anilist/anilist-token-store.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { createAnilistTokenStore, type SafeStorageLike } from './anilist-token-store';
|
||||
|
||||
function createTempTokenFile(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-anilist-token-'));
|
||||
return path.join(dir, 'token.json');
|
||||
}
|
||||
|
||||
function createLogger() {
|
||||
return {
|
||||
info: (_message: string) => {},
|
||||
warn: (_message: string) => {},
|
||||
error: (_message: string) => {},
|
||||
};
|
||||
}
|
||||
|
||||
function createStorage(encryptionAvailable: boolean): SafeStorageLike {
|
||||
return {
|
||||
isEncryptionAvailable: () => encryptionAvailable,
|
||||
encryptString: (value: string) => Buffer.from(`enc:${value}`, 'utf-8'),
|
||||
decryptString: (value: Buffer) => {
|
||||
const raw = value.toString('utf-8');
|
||||
return raw.startsWith('enc:') ? raw.slice(4) : raw;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('anilist token store saves and loads encrypted token', () => {
|
||||
const filePath = createTempTokenFile();
|
||||
const store = createAnilistTokenStore(filePath, createLogger(), createStorage(true));
|
||||
store.saveToken(' demo-token ');
|
||||
|
||||
const payload = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as {
|
||||
encryptedToken?: string;
|
||||
plaintextToken?: string;
|
||||
};
|
||||
assert.equal(typeof payload.encryptedToken, 'string');
|
||||
assert.equal(payload.plaintextToken, undefined);
|
||||
assert.equal(store.loadToken(), 'demo-token');
|
||||
});
|
||||
|
||||
test('anilist token store falls back to plaintext when encryption unavailable', () => {
|
||||
const filePath = createTempTokenFile();
|
||||
const store = createAnilistTokenStore(filePath, createLogger(), createStorage(false));
|
||||
store.saveToken('plain-token');
|
||||
|
||||
const payload = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as {
|
||||
plaintextToken?: string;
|
||||
};
|
||||
assert.equal(payload.plaintextToken, 'plain-token');
|
||||
assert.equal(store.loadToken(), 'plain-token');
|
||||
});
|
||||
|
||||
test('anilist token store migrates legacy plaintext to encrypted', () => {
|
||||
const filePath = createTempTokenFile();
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
JSON.stringify({ plaintextToken: 'legacy-token', updatedAt: Date.now() }),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const store = createAnilistTokenStore(filePath, createLogger(), createStorage(true));
|
||||
assert.equal(store.loadToken(), 'legacy-token');
|
||||
|
||||
const payload = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as {
|
||||
encryptedToken?: string;
|
||||
plaintextToken?: string;
|
||||
};
|
||||
assert.equal(typeof payload.encryptedToken, 'string');
|
||||
assert.equal(payload.plaintextToken, undefined);
|
||||
});
|
||||
|
||||
test('anilist token store clears persisted token file', () => {
|
||||
const filePath = createTempTokenFile();
|
||||
const store = createAnilistTokenStore(filePath, createLogger(), createStorage(true));
|
||||
store.saveToken('to-clear');
|
||||
assert.equal(fs.existsSync(filePath), true);
|
||||
store.clearToken();
|
||||
assert.equal(fs.existsSync(filePath), false);
|
||||
});
|
||||
108
src/core/services/anilist/anilist-token-store.ts
Normal file
108
src/core/services/anilist/anilist-token-store.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as electron from 'electron';
|
||||
|
||||
interface PersistedTokenPayload {
|
||||
encryptedToken?: string;
|
||||
plaintextToken?: string;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
export interface AnilistTokenStore {
|
||||
loadToken: () => string | null;
|
||||
saveToken: (token: string) => void;
|
||||
clearToken: () => void;
|
||||
}
|
||||
|
||||
export interface SafeStorageLike {
|
||||
isEncryptionAvailable: () => boolean;
|
||||
encryptString: (value: string) => Buffer;
|
||||
decryptString: (value: Buffer) => string;
|
||||
}
|
||||
|
||||
function ensureDirectory(filePath: string): void {
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function writePayload(filePath: string, payload: PersistedTokenPayload): void {
|
||||
ensureDirectory(filePath);
|
||||
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
export function createAnilistTokenStore(
|
||||
filePath: string,
|
||||
logger: {
|
||||
info: (message: string) => void;
|
||||
warn: (message: string, details?: unknown) => void;
|
||||
error: (message: string, details?: unknown) => void;
|
||||
},
|
||||
storage: SafeStorageLike = electron.safeStorage,
|
||||
): AnilistTokenStore {
|
||||
return {
|
||||
loadToken(): string | null {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as PersistedTokenPayload;
|
||||
if (typeof parsed.encryptedToken === 'string' && parsed.encryptedToken.length > 0) {
|
||||
const encrypted = Buffer.from(parsed.encryptedToken, 'base64');
|
||||
if (!storage.isEncryptionAvailable()) {
|
||||
logger.warn('AniList token encryption is not available on this system.');
|
||||
return null;
|
||||
}
|
||||
const decrypted = storage.decryptString(encrypted).trim();
|
||||
return decrypted.length > 0 ? decrypted : null;
|
||||
}
|
||||
if (typeof parsed.plaintextToken === 'string' && parsed.plaintextToken.trim().length > 0) {
|
||||
// Legacy fallback: migrate plaintext token to encrypted storage on load.
|
||||
const plaintext = parsed.plaintextToken.trim();
|
||||
this.saveToken(plaintext);
|
||||
return plaintext;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to read AniList token store.', error);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
saveToken(token: string): void {
|
||||
const trimmed = token.trim();
|
||||
if (trimmed.length === 0) {
|
||||
this.clearToken();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!storage.isEncryptionAvailable()) {
|
||||
logger.warn('AniList token encryption unavailable; storing token in plaintext fallback.');
|
||||
writePayload(filePath, {
|
||||
plaintextToken: trimmed,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const encrypted = storage.encryptString(trimmed);
|
||||
writePayload(filePath, {
|
||||
encryptedToken: encrypted.toString('base64'),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to persist AniList token.', error);
|
||||
}
|
||||
},
|
||||
|
||||
clearToken(): void {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
logger.info('Cleared stored AniList token.');
|
||||
} catch (error) {
|
||||
logger.error('Failed to clear stored AniList token.', error);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
93
src/core/services/anilist/anilist-update-queue.test.ts
Normal file
93
src/core/services/anilist/anilist-update-queue.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { createAnilistUpdateQueue } from './anilist-update-queue';
|
||||
|
||||
function createTempQueueFile(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-anilist-queue-'));
|
||||
return path.join(dir, 'queue.json');
|
||||
}
|
||||
|
||||
function createLogger() {
|
||||
const info: string[] = [];
|
||||
const warn: string[] = [];
|
||||
const error: string[] = [];
|
||||
return {
|
||||
info,
|
||||
warn,
|
||||
error,
|
||||
logger: {
|
||||
info: (message: string) => info.push(message),
|
||||
warn: (message: string) => warn.push(message),
|
||||
error: (message: string) => error.push(message),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('anilist update queue enqueues, snapshots, and dequeues success', () => {
|
||||
const queueFile = createTempQueueFile();
|
||||
const loggerState = createLogger();
|
||||
const queue = createAnilistUpdateQueue(queueFile, loggerState.logger);
|
||||
|
||||
queue.enqueue('k1', 'Demo', 1);
|
||||
const snapshot = queue.getSnapshot(Number.MAX_SAFE_INTEGER);
|
||||
assert.deepEqual(snapshot, { pending: 1, ready: 1, deadLetter: 0 });
|
||||
assert.equal(queue.nextReady(Number.MAX_SAFE_INTEGER)?.key, 'k1');
|
||||
|
||||
queue.markSuccess('k1');
|
||||
assert.deepEqual(queue.getSnapshot(Number.MAX_SAFE_INTEGER), {
|
||||
pending: 0,
|
||||
ready: 0,
|
||||
deadLetter: 0,
|
||||
});
|
||||
assert.ok(loggerState.info.some((message) => message.includes('Queued AniList retry')));
|
||||
});
|
||||
|
||||
test('anilist update queue applies retry backoff and dead-letter', () => {
|
||||
const queueFile = createTempQueueFile();
|
||||
const loggerState = createLogger();
|
||||
const queue = createAnilistUpdateQueue(queueFile, loggerState.logger);
|
||||
|
||||
const now = 1_700_000_000_000;
|
||||
queue.enqueue('k2', 'Backoff Demo', 2);
|
||||
|
||||
queue.markFailure('k2', 'fail-1', now);
|
||||
const firstRetry = queue.nextReady(now);
|
||||
assert.equal(firstRetry, null);
|
||||
|
||||
const pendingPayload = JSON.parse(fs.readFileSync(queueFile, 'utf-8')) as {
|
||||
pending: Array<{ attemptCount: number; nextAttemptAt: number }>;
|
||||
};
|
||||
assert.equal(pendingPayload.pending[0]?.attemptCount, 1);
|
||||
assert.equal(pendingPayload.pending[0]?.nextAttemptAt, now + 30_000);
|
||||
|
||||
for (let attempt = 2; attempt <= 8; attempt += 1) {
|
||||
queue.markFailure('k2', `fail-${attempt}`, now);
|
||||
}
|
||||
|
||||
const snapshot = queue.getSnapshot(Number.MAX_SAFE_INTEGER);
|
||||
assert.deepEqual(snapshot, { pending: 0, ready: 0, deadLetter: 1 });
|
||||
assert.ok(
|
||||
loggerState.warn.some((message) =>
|
||||
message.includes('AniList retry moved to dead-letter queue.'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('anilist update queue persists and reloads from disk', () => {
|
||||
const queueFile = createTempQueueFile();
|
||||
const loggerState = createLogger();
|
||||
const queueA = createAnilistUpdateQueue(queueFile, loggerState.logger);
|
||||
queueA.enqueue('k3', 'Persist Demo', 3);
|
||||
|
||||
const queueB = createAnilistUpdateQueue(queueFile, loggerState.logger);
|
||||
assert.deepEqual(queueB.getSnapshot(Number.MAX_SAFE_INTEGER), {
|
||||
pending: 1,
|
||||
ready: 1,
|
||||
deadLetter: 0,
|
||||
});
|
||||
assert.equal(queueB.nextReady(Number.MAX_SAFE_INTEGER)?.title, 'Persist Demo');
|
||||
});
|
||||
193
src/core/services/anilist/anilist-update-queue.ts
Normal file
193
src/core/services/anilist/anilist-update-queue.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const INITIAL_BACKOFF_MS = 30_000;
|
||||
const MAX_BACKOFF_MS = 6 * 60 * 60 * 1000;
|
||||
const MAX_ATTEMPTS = 8;
|
||||
const MAX_ITEMS = 500;
|
||||
|
||||
export interface AnilistQueuedUpdate {
|
||||
key: string;
|
||||
title: string;
|
||||
episode: number;
|
||||
createdAt: number;
|
||||
attemptCount: number;
|
||||
nextAttemptAt: number;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
interface AnilistRetryQueuePayload {
|
||||
pending?: AnilistQueuedUpdate[];
|
||||
deadLetter?: AnilistQueuedUpdate[];
|
||||
}
|
||||
|
||||
export interface AnilistRetryQueueSnapshot {
|
||||
pending: number;
|
||||
ready: number;
|
||||
deadLetter: number;
|
||||
}
|
||||
|
||||
export interface AnilistUpdateQueue {
|
||||
enqueue: (key: string, title: string, episode: number) => void;
|
||||
nextReady: (nowMs?: number) => AnilistQueuedUpdate | null;
|
||||
markSuccess: (key: string) => void;
|
||||
markFailure: (key: string, reason: string, nowMs?: number) => void;
|
||||
getSnapshot: (nowMs?: number) => AnilistRetryQueueSnapshot;
|
||||
}
|
||||
|
||||
function ensureDir(filePath: string): void {
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function clampBackoffMs(attemptCount: number): number {
|
||||
const computed = INITIAL_BACKOFF_MS * Math.pow(2, Math.max(0, attemptCount - 1));
|
||||
return Math.min(MAX_BACKOFF_MS, computed);
|
||||
}
|
||||
|
||||
export function createAnilistUpdateQueue(
|
||||
filePath: string,
|
||||
logger: {
|
||||
info: (message: string) => void;
|
||||
warn: (message: string, details?: unknown) => void;
|
||||
error: (message: string, details?: unknown) => void;
|
||||
},
|
||||
): AnilistUpdateQueue {
|
||||
let pending: AnilistQueuedUpdate[] = [];
|
||||
let deadLetter: AnilistQueuedUpdate[] = [];
|
||||
|
||||
const persist = () => {
|
||||
try {
|
||||
ensureDir(filePath);
|
||||
const payload: AnilistRetryQueuePayload = { pending, deadLetter };
|
||||
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8');
|
||||
} catch (error) {
|
||||
logger.error('Failed to persist AniList retry queue.', error);
|
||||
}
|
||||
};
|
||||
|
||||
const load = () => {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as AnilistRetryQueuePayload;
|
||||
const parsedPending = Array.isArray(parsed.pending) ? parsed.pending : [];
|
||||
const parsedDeadLetter = Array.isArray(parsed.deadLetter) ? parsed.deadLetter : [];
|
||||
pending = parsedPending
|
||||
.filter(
|
||||
(item): item is AnilistQueuedUpdate =>
|
||||
item &&
|
||||
typeof item.key === 'string' &&
|
||||
typeof item.title === 'string' &&
|
||||
typeof item.episode === 'number' &&
|
||||
item.episode > 0 &&
|
||||
typeof item.createdAt === 'number' &&
|
||||
typeof item.attemptCount === 'number' &&
|
||||
typeof item.nextAttemptAt === 'number' &&
|
||||
(typeof item.lastError === 'string' || item.lastError === null),
|
||||
)
|
||||
.slice(0, MAX_ITEMS);
|
||||
deadLetter = parsedDeadLetter
|
||||
.filter(
|
||||
(item): item is AnilistQueuedUpdate =>
|
||||
item &&
|
||||
typeof item.key === 'string' &&
|
||||
typeof item.title === 'string' &&
|
||||
typeof item.episode === 'number' &&
|
||||
item.episode > 0 &&
|
||||
typeof item.createdAt === 'number' &&
|
||||
typeof item.attemptCount === 'number' &&
|
||||
typeof item.nextAttemptAt === 'number' &&
|
||||
(typeof item.lastError === 'string' || item.lastError === null),
|
||||
)
|
||||
.slice(0, MAX_ITEMS);
|
||||
} catch (error) {
|
||||
logger.error('Failed to load AniList retry queue.', error);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
|
||||
return {
|
||||
enqueue(key: string, title: string, episode: number): void {
|
||||
const existing = pending.find((item) => item.key === key);
|
||||
if (existing) {
|
||||
return;
|
||||
}
|
||||
if (pending.length >= MAX_ITEMS) {
|
||||
pending.shift();
|
||||
}
|
||||
pending.push({
|
||||
key,
|
||||
title,
|
||||
episode,
|
||||
createdAt: Date.now(),
|
||||
attemptCount: 0,
|
||||
nextAttemptAt: Date.now(),
|
||||
lastError: null,
|
||||
});
|
||||
persist();
|
||||
logger.info(`Queued AniList retry for "${title}" episode ${episode}.`);
|
||||
},
|
||||
|
||||
nextReady(nowMs: number = Date.now()): AnilistQueuedUpdate | null {
|
||||
const ready = pending.find((item) => item.nextAttemptAt <= nowMs);
|
||||
return ready ?? null;
|
||||
},
|
||||
|
||||
markSuccess(key: string): void {
|
||||
const before = pending.length;
|
||||
pending = pending.filter((item) => item.key !== key);
|
||||
if (pending.length !== before) {
|
||||
persist();
|
||||
}
|
||||
},
|
||||
|
||||
markFailure(key: string, reason: string, nowMs: number = Date.now()): void {
|
||||
const item = pending.find((candidate) => candidate.key === key);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
item.attemptCount += 1;
|
||||
item.lastError = reason;
|
||||
if (item.attemptCount >= MAX_ATTEMPTS) {
|
||||
pending = pending.filter((candidate) => candidate.key !== key);
|
||||
if (deadLetter.length >= MAX_ITEMS) {
|
||||
deadLetter.shift();
|
||||
}
|
||||
deadLetter.push({
|
||||
...item,
|
||||
nextAttemptAt: nowMs,
|
||||
});
|
||||
logger.warn('AniList retry moved to dead-letter queue.', {
|
||||
key,
|
||||
reason,
|
||||
attempts: item.attemptCount,
|
||||
});
|
||||
persist();
|
||||
return;
|
||||
}
|
||||
item.nextAttemptAt = nowMs + clampBackoffMs(item.attemptCount);
|
||||
persist();
|
||||
logger.warn('AniList retry scheduled with backoff.', {
|
||||
key,
|
||||
attemptCount: item.attemptCount,
|
||||
nextAttemptAt: item.nextAttemptAt,
|
||||
reason,
|
||||
});
|
||||
},
|
||||
|
||||
getSnapshot(nowMs: number = Date.now()): AnilistRetryQueueSnapshot {
|
||||
const ready = pending.filter((item) => item.nextAttemptAt <= nowMs).length;
|
||||
return {
|
||||
pending: pending.length,
|
||||
ready,
|
||||
deadLetter: deadLetter.length,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
166
src/core/services/anilist/anilist-updater.test.ts
Normal file
166
src/core/services/anilist/anilist-updater.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as childProcess from 'child_process';
|
||||
|
||||
import { guessAnilistMediaInfo, updateAnilistPostWatchProgress } from './anilist-updater';
|
||||
|
||||
function createJsonResponse(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
test('guessAnilistMediaInfo uses guessit output when available', async () => {
|
||||
const originalExecFile = childProcess.execFile;
|
||||
(
|
||||
childProcess as unknown as {
|
||||
execFile: typeof childProcess.execFile;
|
||||
}
|
||||
).execFile = ((...args: unknown[]) => {
|
||||
const callback = args[args.length - 1];
|
||||
const cb =
|
||||
typeof callback === 'function'
|
||||
? (callback as (error: Error | null, stdout: string, stderr: string) => void)
|
||||
: null;
|
||||
cb?.(null, JSON.stringify({ title: 'Guessit Title', episode: 7 }), '');
|
||||
return {} as childProcess.ChildProcess;
|
||||
}) as typeof childProcess.execFile;
|
||||
|
||||
try {
|
||||
const result = await guessAnilistMediaInfo('/tmp/demo.mkv', null);
|
||||
assert.deepEqual(result, {
|
||||
title: 'Guessit Title',
|
||||
episode: 7,
|
||||
source: 'guessit',
|
||||
});
|
||||
} finally {
|
||||
(
|
||||
childProcess as unknown as {
|
||||
execFile: typeof childProcess.execFile;
|
||||
}
|
||||
).execFile = originalExecFile;
|
||||
}
|
||||
});
|
||||
|
||||
test('guessAnilistMediaInfo falls back to parser when guessit fails', async () => {
|
||||
const originalExecFile = childProcess.execFile;
|
||||
(
|
||||
childProcess as unknown as {
|
||||
execFile: typeof childProcess.execFile;
|
||||
}
|
||||
).execFile = ((...args: unknown[]) => {
|
||||
const callback = args[args.length - 1];
|
||||
const cb =
|
||||
typeof callback === 'function'
|
||||
? (callback as (error: Error | null, stdout: string, stderr: string) => void)
|
||||
: null;
|
||||
cb?.(new Error('guessit not found'), '', '');
|
||||
return {} as childProcess.ChildProcess;
|
||||
}) as typeof childProcess.execFile;
|
||||
|
||||
try {
|
||||
const result = await guessAnilistMediaInfo('/tmp/My Anime S01E03.mkv', null);
|
||||
assert.deepEqual(result, {
|
||||
title: 'My Anime',
|
||||
episode: 3,
|
||||
source: 'fallback',
|
||||
});
|
||||
} finally {
|
||||
(
|
||||
childProcess as unknown as {
|
||||
execFile: typeof childProcess.execFile;
|
||||
}
|
||||
).execFile = originalExecFile;
|
||||
}
|
||||
});
|
||||
|
||||
test('updateAnilistPostWatchProgress updates progress when behind', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let call = 0;
|
||||
globalThis.fetch = (async () => {
|
||||
call += 1;
|
||||
if (call === 1) {
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
Page: {
|
||||
media: [
|
||||
{
|
||||
id: 11,
|
||||
episodes: 24,
|
||||
title: { english: 'Demo Show', romaji: 'Demo Show' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (call === 2) {
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
Media: {
|
||||
id: 11,
|
||||
mediaListEntry: { progress: 2, status: 'CURRENT' },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return createJsonResponse({
|
||||
data: { SaveMediaListEntry: { progress: 3, status: 'CURRENT' } },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await updateAnilistPostWatchProgress('token', 'Demo Show', 3);
|
||||
assert.equal(result.status, 'updated');
|
||||
assert.match(result.message, /episode 3/i);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('updateAnilistPostWatchProgress skips when progress already reached', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let call = 0;
|
||||
globalThis.fetch = (async () => {
|
||||
call += 1;
|
||||
if (call === 1) {
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
Page: {
|
||||
media: [{ id: 22, episodes: 12, title: { english: 'Skip Show' } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
Media: { id: 22, mediaListEntry: { progress: 12, status: 'CURRENT' } },
|
||||
},
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await updateAnilistPostWatchProgress('token', 'Skip Show', 10);
|
||||
assert.equal(result.status, 'skipped');
|
||||
assert.match(result.message, /already at episode/i);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('updateAnilistPostWatchProgress returns error when search fails', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
createJsonResponse({
|
||||
errors: [{ message: 'bad request' }],
|
||||
})) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await updateAnilistPostWatchProgress('token', 'Bad', 1);
|
||||
assert.equal(result.status, 'error');
|
||||
assert.match(result.message, /search failed/i);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
299
src/core/services/anilist/anilist-updater.ts
Normal file
299
src/core/services/anilist/anilist-updater.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
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}.`,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user