Fix macOS overlay foreground handling and character-dictionary cache reuse (#68)

This commit is contained in:
2026-05-16 20:43:01 -07:00
committed by GitHub
parent 89723e2ccb
commit 49f89e6452
55 changed files with 2088 additions and 227 deletions
@@ -32,10 +32,16 @@ test('anilist update queue enqueues, snapshots, and dequeues success', () => {
const loggerState = createLogger();
const queue = createAnilistUpdateQueue(queueFile, loggerState.logger);
queue.enqueue('k1', 'Demo', 1);
queue.enqueue('k1', 'Demo', 1, 2);
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');
assert.deepEqual(
{
key: queue.nextReady(Number.MAX_SAFE_INTEGER)?.key,
season: queue.nextReady(Number.MAX_SAFE_INTEGER)?.season,
},
{ key: 'k1', season: 2 },
);
queue.markSuccess('k1');
assert.deepEqual(queue.getSnapshot(Number.MAX_SAFE_INTEGER), {
@@ -9,6 +9,7 @@ const MAX_ITEMS = 500;
export interface AnilistQueuedUpdate {
key: string;
title: string;
season?: number | null;
episode: number;
createdAt: number;
attemptCount: number;
@@ -28,7 +29,7 @@ export interface AnilistRetryQueueSnapshot {
}
export interface AnilistUpdateQueue {
enqueue: (key: string, title: string, episode: number) => void;
enqueue: (key: string, title: string, episode: number, season?: number | null) => void;
nextReady: (nowMs?: number) => AnilistQueuedUpdate | null;
markSuccess: (key: string) => void;
markFailure: (key: string, reason: string, nowMs?: number) => void;
@@ -106,7 +107,7 @@ export function createAnilistUpdateQueue(
load();
return {
enqueue(key: string, title: string, episode: number): void {
enqueue(key: string, title: string, episode: number, season: number | null = null): void {
const existing = pending.find((item) => item.key === key);
if (existing) {
return;
@@ -117,6 +118,7 @@ export function createAnilistUpdateQueue(
pending.push({
key,
title,
season,
episode,
createdAt: Date.now(),
attemptCount: 0,
@@ -265,6 +265,125 @@ test('updateAnilistPostWatchProgress skips when progress already reached', async
}
});
test('updateAnilistPostWatchProgress returns non-retryable error when media is not planning or watching', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
globalThis.fetch = (async () => {
call += 1;
if (call === 1) {
return createJsonResponse({
data: {
Page: {
media: [{ id: 33, episodes: 12, title: { english: 'Missing Show' } }],
},
},
});
}
return createJsonResponse({
data: {
Media: { id: 33, mediaListEntry: null },
},
});
}) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Missing Show', 2);
assert.equal(result.status, 'error');
assert.equal(result.retryable, false);
assert.match(result.message, /not in your AniList Planning or Watching list/i);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress prefers season-specific AniList matches', async () => {
const originalFetch = globalThis.fetch;
const searchTerms: string[] = [];
let call = 0;
globalThis.fetch = (async (_input, init) => {
call += 1;
const body = JSON.parse(String(init?.body)) as { variables?: Record<string, unknown> };
if (call === 1) {
searchTerms.push(String(body.variables?.search));
return createJsonResponse({
data: {
Page: {
media: [
{ id: 202, episodes: 12, title: { english: 'Demo Show Season 2' } },
{ id: 101, episodes: 12, title: { english: 'Demo Show' } },
],
},
},
});
}
if (call === 2) {
assert.equal(body.variables?.mediaId, 202);
return createJsonResponse({
data: {
Media: { id: 202, mediaListEntry: null },
},
});
}
return createJsonResponse({
data: {
SaveMediaListEntry: { progress: 2, status: 'CURRENT' },
},
});
}) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Demo Show', 2, {
season: 2,
});
assert.deepEqual(searchTerms, ['Demo Show Season 2']);
assert.equal(result.status, 'error');
assert.equal(result.retryable, false);
assert.match(result.message, /not in your AniList Planning or Watching list/i);
assert.equal(call, 2);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress does not update rewatching entries', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
globalThis.fetch = (async () => {
call += 1;
if (call === 1) {
return createJsonResponse({
data: {
Page: {
media: [{ id: 44, episodes: 12, title: { english: 'Rewatch Show' } }],
},
},
});
}
if (call === 2) {
return createJsonResponse({
data: {
Media: { id: 44, mediaListEntry: { progress: 0, status: 'REPEATING' } },
},
});
}
return createJsonResponse({
data: {
SaveMediaListEntry: { progress: 2, status: 'CURRENT' },
},
});
}) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Rewatch Show', 2);
assert.equal(result.status, 'error');
assert.equal(result.retryable, false);
assert.match(result.message, /marked repeating on AniList/i);
assert.equal(call, 2);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress returns error when search fails', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
+77 -21
View File
@@ -18,10 +18,12 @@ export interface AnilistMediaGuess {
export interface AnilistPostWatchUpdateResult {
status: 'updated' | 'skipped' | 'error';
message: string;
retryable?: boolean;
}
export interface AnilistPostWatchUpdateOptions {
rateLimiter?: AnilistRateLimiter;
season?: number | null;
}
interface AnilistGraphQlError {
@@ -156,6 +158,28 @@ function normalizeTitle(text: string): string {
return text.trim().toLowerCase().replace(/\s+/g, ' ');
}
function titleMentionsSeason(title: string, season: number): boolean {
const normalized = normalizeTitle(title);
return (
normalized.includes(`season ${season}`) ||
normalized.includes(`s${String(season).padStart(2, '0')}`) ||
normalized.includes(`s${season}`)
);
}
function buildSearchCandidates(title: string, season: number | null | undefined): string[] {
const trimmed = title.trim();
if (!trimmed) return [];
const candidates =
typeof season === 'number' &&
Number.isInteger(season) &&
season > 1 &&
!titleMentionsSeason(trimmed, season)
? [`${trimmed} Season ${season}`, trimmed]
: [trimmed];
return candidates.filter((candidate, index, all) => all.indexOf(candidate) === index);
}
async function anilistGraphQl<T>(
accessToken: string,
query: string,
@@ -226,6 +250,15 @@ function pickBestSearchResult(
return { id: selected.id, title: selectedTitle };
}
function isUpdateableListStatus(status: string | null | undefined): boolean {
return status === 'CURRENT' || status === 'PLANNING';
}
function formatListStatus(status: string | null | undefined): string {
if (!status) return 'not in your AniList Planning or Watching list';
return `marked ${status.toLowerCase().replace(/_/g, ' ')} on AniList`;
}
export async function guessAnilistMediaInfo(
mediaPath: string | null,
mediaTitle: string | null,
@@ -279,27 +312,42 @@ export async function updateAnilistPostWatchProgress(
episode: number,
options: AnilistPostWatchUpdateOptions = {},
): 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
let media: NonNullable<NonNullable<AnilistSearchData['Page']>['media']> = [];
let searchError: string | null = null;
let pickTitle = title;
const searchCandidates = buildSearchCandidates(title, options.season);
for (const search of searchCandidates) {
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 },
options,
);
const searchError = firstErrorMessage(searchResponse);
`,
{ search },
options,
);
searchError = firstErrorMessage(searchResponse);
if (searchError) {
break;
}
media = searchResponse.data?.Page?.media ?? [];
if (media.length > 0) {
pickTitle = search;
break;
}
}
if (searchError) {
return {
status: 'error',
@@ -307,8 +355,7 @@ export async function updateAnilistPostWatchProgress(
};
}
const media = searchResponse.data?.Page?.media ?? [];
const picked = pickBestSearchResult(title, episode, media);
const picked = pickBestSearchResult(pickTitle, episode, media);
if (!picked) {
return { status: 'error', message: 'AniList search returned no matches.' };
}
@@ -337,7 +384,16 @@ export async function updateAnilistPostWatchProgress(
};
}
const currentProgress = entryResponse.data?.Media?.mediaListEntry?.progress ?? 0;
const entry = entryResponse.data?.Media?.mediaListEntry ?? null;
if (!entry || !isUpdateableListStatus(entry.status)) {
return {
status: 'error',
retryable: false,
message: `AniList update not possible: "${picked.title}" is ${formatListStatus(entry?.status)}. Add it to Planning or Watching, then mark watched again.`,
};
}
const currentProgress = entry.progress ?? 0;
if (typeof currentProgress === 'number' && currentProgress >= episode) {
return {
status: 'skipped',