mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
feat: inject bundled mpv plugin for managed launches, remove legacy glob (#62)
* feat: inject bundled mpv plugin for managed launches, remove legacy glob - SubMiner-managed launcher and Windows shortcut launches inject the bundled plugin when no global plugin is detected - First-run setup detects and removes legacy global plugin files via OS trash before managed playback starts - Makefile `install-plugin` target and Windows config-rewrite script removed; Linux/macOS install now copies plugin to app data dir - AniList stats search and post-watch tracking now go through the shared rate limiter - Stats cover-art lookup reuses cached AniList data before issuing a new request - Closing mpv in a launcher-managed session now terminates the background Electron app * harden bootstrap version load and clean plugin on uninstall - Use pcall for version.lua in bootstrap.lua so missing version module does not crash plugin startup - Remove plugin/subminer from app-data dirs in uninstall-linux and uninstall-macos targets - Add Lua compat test asserting bootstrap uses defensive pcall for version load - Add release-workflow test asserting uninstall targets clean bundled plugin dirs - Delete completed planning document
This commit is contained in:
@@ -1025,6 +1025,46 @@ describe('stats server API routes', () => {
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
it('GET /api/stats/anilist/search uses the configured AniList rate limiter', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let acquireCalls = 0;
|
||||
let recordCalls = 0;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
Page: {
|
||||
media: [{ id: 21858, title: { romaji: 'Little Witch Academia' } }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json', 'X-RateLimit-Remaining': '29' },
|
||||
},
|
||||
)) as typeof fetch;
|
||||
|
||||
try {
|
||||
const app = createStatsApp(createMockTracker(), {
|
||||
anilistRateLimiter: {
|
||||
acquire: async () => {
|
||||
acquireCalls += 1;
|
||||
},
|
||||
recordResponse: () => {
|
||||
recordCalls += 1;
|
||||
},
|
||||
},
|
||||
});
|
||||
const res = await app.request('/api/stats/anilist/search?q=Little%20Witch%20Academia');
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(acquireCalls, 1);
|
||||
assert.equal(recordCalls, 1);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('POST /api/stats/anki/notesInfo resolves stale note ids through the configured alias resolver', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requests: unknown[] = [];
|
||||
|
||||
@@ -184,6 +184,57 @@ test('updateAnilistPostWatchProgress updates progress when behind', async () =>
|
||||
}
|
||||
});
|
||||
|
||||
test('updateAnilistPostWatchProgress uses the configured AniList rate limiter', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let call = 0;
|
||||
let acquireCalls = 0;
|
||||
let recordCalls = 0;
|
||||
globalThis.fetch = (async () => {
|
||||
call += 1;
|
||||
if (call === 1) {
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
Page: {
|
||||
media: [{ id: 11, episodes: 24, title: { english: '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, {
|
||||
rateLimiter: {
|
||||
acquire: async () => {
|
||||
acquireCalls += 1;
|
||||
},
|
||||
recordResponse: () => {
|
||||
recordCalls += 1;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'updated');
|
||||
assert.equal(acquireCalls, 3);
|
||||
assert.equal(recordCalls, 3);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('updateAnilistPostWatchProgress skips when progress already reached', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let call = 0;
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as childProcess from 'child_process';
|
||||
import * as path from 'path';
|
||||
|
||||
import { parseMediaInfo } from '../../../jimaku/utils';
|
||||
import type { AnilistRateLimiter } from './rate-limiter';
|
||||
|
||||
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
|
||||
|
||||
@@ -19,6 +20,10 @@ export interface AnilistPostWatchUpdateResult {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface AnilistPostWatchUpdateOptions {
|
||||
rateLimiter?: AnilistRateLimiter;
|
||||
}
|
||||
|
||||
interface AnilistGraphQlError {
|
||||
message?: string;
|
||||
}
|
||||
@@ -155,8 +160,10 @@ async function anilistGraphQl<T>(
|
||||
accessToken: string,
|
||||
query: string,
|
||||
variables: Record<string, unknown>,
|
||||
options: AnilistPostWatchUpdateOptions = {},
|
||||
): Promise<AnilistGraphQlResponse<T>> {
|
||||
try {
|
||||
await options.rateLimiter?.acquire();
|
||||
const response = await fetch(ANILIST_GRAPHQL_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -166,6 +173,7 @@ async function anilistGraphQl<T>(
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
|
||||
options.rateLimiter?.recordResponse(response.headers);
|
||||
const payload = (await response.json()) as AnilistGraphQlResponse<T>;
|
||||
return payload;
|
||||
} catch (error) {
|
||||
@@ -269,6 +277,7 @@ export async function updateAnilistPostWatchProgress(
|
||||
accessToken: string,
|
||||
title: string,
|
||||
episode: number,
|
||||
options: AnilistPostWatchUpdateOptions = {},
|
||||
): Promise<AnilistPostWatchUpdateResult> {
|
||||
const searchResponse = await anilistGraphQl<AnilistSearchData>(
|
||||
accessToken,
|
||||
@@ -288,6 +297,7 @@ export async function updateAnilistPostWatchProgress(
|
||||
}
|
||||
`,
|
||||
{ search: title },
|
||||
options,
|
||||
);
|
||||
const searchError = firstErrorMessage(searchResponse);
|
||||
if (searchError) {
|
||||
@@ -317,6 +327,7 @@ export async function updateAnilistPostWatchProgress(
|
||||
}
|
||||
`,
|
||||
{ mediaId: picked.id },
|
||||
options,
|
||||
);
|
||||
const entryError = firstErrorMessage(entryResponse);
|
||||
if (entryError) {
|
||||
@@ -345,6 +356,7 @@ export async function updateAnilistPostWatchProgress(
|
||||
}
|
||||
`,
|
||||
{ mediaId: picked.id, progress: episode },
|
||||
options,
|
||||
);
|
||||
const saveError = firstErrorMessage(saveResponse);
|
||||
if (saveError) {
|
||||
|
||||
@@ -5,7 +5,12 @@ import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { createCoverArtFetcher, stripFilenameTags } from './cover-art-fetcher.js';
|
||||
import { Database } from '../immersion-tracker/sqlite.js';
|
||||
import { ensureSchema, getOrCreateVideoRecord } from '../immersion-tracker/storage.js';
|
||||
import {
|
||||
ensureSchema,
|
||||
getOrCreateAnimeRecord,
|
||||
getOrCreateVideoRecord,
|
||||
linkVideoToAnimeRecord,
|
||||
} from '../immersion-tracker/storage.js';
|
||||
import { getCoverArt } from '../immersion-tracker/query-library.js';
|
||||
import { upsertCoverArt } from '../immersion-tracker/query-maintenance.js';
|
||||
import { SOURCE_TYPE_LOCAL } from '../immersion-tracker/types.js';
|
||||
@@ -100,6 +105,82 @@ test('fetchIfMissing backfills a missing blob from an existing cover URL', async
|
||||
}
|
||||
});
|
||||
|
||||
test('fetchIfMissing reuses cached cover art from another video in the same anime', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
ensureSchema(db);
|
||||
const firstVideoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-cache-1.mkv', {
|
||||
canonicalTitle: 'Shared Cover Show',
|
||||
sourcePath: '/tmp/cover-fetcher-cache-1.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
const secondVideoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-cache-2.mkv', {
|
||||
canonicalTitle: 'Shared Cover Show',
|
||||
sourcePath: '/tmp/cover-fetcher-cache-2.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
const animeId = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: 'Shared Cover Show',
|
||||
canonicalTitle: 'Shared Cover Show',
|
||||
anilistId: 99,
|
||||
titleRomaji: 'Shared Cover Show',
|
||||
titleEnglish: 'Shared Cover Show',
|
||||
titleNative: null,
|
||||
metadataJson: null,
|
||||
});
|
||||
for (const videoId of [firstVideoId, secondVideoId]) {
|
||||
linkVideoToAnimeRecord(db, videoId, {
|
||||
animeId,
|
||||
parsedBasename: null,
|
||||
parsedTitle: 'Shared Cover Show',
|
||||
parsedSeason: 1,
|
||||
parsedEpisode: videoId,
|
||||
parserSource: 'fallback',
|
||||
parserConfidence: 1,
|
||||
parseMetadataJson: null,
|
||||
});
|
||||
}
|
||||
upsertCoverArt(db, firstVideoId, {
|
||||
anilistId: 99,
|
||||
coverUrl: 'https://images.test/shared-cover.jpg',
|
||||
coverBlob: Buffer.from([9, 8, 7, 6]),
|
||||
titleRomaji: 'Shared Cover Show',
|
||||
titleEnglish: 'Shared Cover Show',
|
||||
episodesTotal: 12,
|
||||
});
|
||||
|
||||
let fetchCalls = 0;
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () => {
|
||||
fetchCalls += 1;
|
||||
throw new Error('unexpected AniList or image request');
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const fetcher = createCoverArtFetcher(
|
||||
{
|
||||
acquire: async () => {},
|
||||
recordResponse: () => {},
|
||||
},
|
||||
console,
|
||||
);
|
||||
|
||||
const fetched = await fetcher.fetchIfMissing(db, secondVideoId, 'Shared Cover Show');
|
||||
const stored = getCoverArt(db, secondVideoId);
|
||||
|
||||
assert.equal(fetched, true);
|
||||
assert.equal(fetchCalls, 0);
|
||||
assert.equal(stored?.anilistId, 99);
|
||||
assert.equal(Buffer.from(stored?.coverBlob ?? []).toString('hex'), '09080706');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
function createJsonResponse(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { AnilistRateLimiter } from './rate-limiter';
|
||||
import type { DatabaseSync } from '../immersion-tracker/sqlite';
|
||||
import { getCoverArt, upsertCoverArt, updateAnimeAnilistInfo } from '../immersion-tracker/query';
|
||||
import {
|
||||
getAnimeCoverArt,
|
||||
getCoverArt,
|
||||
upsertCoverArt,
|
||||
updateAnimeAnilistInfo,
|
||||
} from '../immersion-tracker/query';
|
||||
import {
|
||||
guessAnilistMediaInfo,
|
||||
runGuessit,
|
||||
@@ -257,6 +262,30 @@ export function createCoverArtFetcher(
|
||||
logger: Logger,
|
||||
options: CoverArtFetcherOptions = {},
|
||||
): CoverArtFetcher {
|
||||
const reuseAnimeCoverArt = (db: DatabaseSync, videoId: number): boolean => {
|
||||
const row = db
|
||||
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?')
|
||||
.get(videoId) as { animeId: number | null } | undefined;
|
||||
if (!row?.animeId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const shared = getAnimeCoverArt(db, row.animeId);
|
||||
if (!shared?.coverBlob) {
|
||||
return false;
|
||||
}
|
||||
|
||||
upsertCoverArt(db, videoId, {
|
||||
anilistId: shared.anilistId,
|
||||
coverUrl: shared.coverUrl,
|
||||
coverBlob: shared.coverBlob,
|
||||
titleRomaji: shared.titleRomaji,
|
||||
titleEnglish: shared.titleEnglish,
|
||||
episodesTotal: shared.episodesTotal,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const resolveCanonicalTitle = (
|
||||
db: DatabaseSync,
|
||||
videoId: number,
|
||||
@@ -317,6 +346,10 @@ export function createCoverArtFetcher(
|
||||
}
|
||||
}
|
||||
|
||||
if (reuseAnimeCoverArt(db, videoId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
existing &&
|
||||
existing.coverUrl === null &&
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getPreferredNoteFieldValue,
|
||||
} from '../../anki-field-config.js';
|
||||
import { resolveAnimatedImageLeadInSeconds } from '../../anki-integration/animated-image-sync.js';
|
||||
import type { AnilistRateLimiter } from './anilist/rate-limiter.js';
|
||||
|
||||
type StatsServerNoteInfo = {
|
||||
noteId: number;
|
||||
@@ -255,6 +256,7 @@ export interface StatsServerConfig {
|
||||
knownWordCachePath?: string;
|
||||
mpvSocketPath?: string;
|
||||
ankiConnectConfig?: AnkiConnectConfig;
|
||||
anilistRateLimiter?: AnilistRateLimiter;
|
||||
addYomitanNote?: (word: string) => Promise<number | null>;
|
||||
resolveAnkiNoteId?: (noteId: number) => number;
|
||||
}
|
||||
@@ -338,6 +340,7 @@ export function createStatsApp(
|
||||
knownWordCachePath?: string;
|
||||
mpvSocketPath?: string;
|
||||
ankiConnectConfig?: AnkiConnectConfig;
|
||||
anilistRateLimiter?: AnilistRateLimiter;
|
||||
addYomitanNote?: (word: string) => Promise<number | null>;
|
||||
resolveAnkiNoteId?: (noteId: number) => number;
|
||||
},
|
||||
@@ -632,6 +635,7 @@ export function createStatsApp(
|
||||
const query = (c.req.query('q') ?? '').trim();
|
||||
if (!query) return c.json([]);
|
||||
try {
|
||||
await options?.anilistRateLimiter?.acquire();
|
||||
const res = await fetch('https://graphql.anilist.co', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -652,6 +656,10 @@ export function createStatsApp(
|
||||
variables: { search: query },
|
||||
}),
|
||||
});
|
||||
options?.anilistRateLimiter?.recordResponse(res.headers);
|
||||
if (res.status === 429) {
|
||||
return c.json([]);
|
||||
}
|
||||
const json = (await res.json()) as { data?: { Page?: { media?: unknown[] } } };
|
||||
return c.json(json.data?.Page?.media ?? []);
|
||||
} catch {
|
||||
@@ -1131,6 +1139,7 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
|
||||
knownWordCachePath: config.knownWordCachePath,
|
||||
mpvSocketPath: config.mpvSocketPath,
|
||||
ankiConnectConfig: config.ankiConnectConfig,
|
||||
anilistRateLimiter: config.anilistRateLimiter,
|
||||
addYomitanNote: config.addYomitanNote,
|
||||
resolveAnkiNoteId: config.resolveAnkiNoteId,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user