mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-30 07:21:32 -07:00
Fix macOS overlay foreground handling and character-dictionary cache reuse (#68)
This commit is contained in:
@@ -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 () =>
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user