diff --git a/packages/obsidian/src/library/LibraryController.ts b/packages/obsidian/src/library/LibraryController.ts new file mode 100644 index 0000000..9a83a13 --- /dev/null +++ b/packages/obsidian/src/library/LibraryController.ts @@ -0,0 +1,211 @@ +import { Notice, TFile, TFolder } from 'obsidian'; +import { bookSpec } from 'packages/obsidian/src/library/book'; +import { comicSpec } from 'packages/obsidian/src/library/comic'; +import { gameSpec } from 'packages/obsidian/src/library/game'; +import { libraryFolderResolve, libraryFolderSync, type LibraryEngineDeps, type LibraryReport, type LibraryResolveReport } from 'packages/obsidian/src/library/LibraryEngine'; +import { mangaSpec } from 'packages/obsidian/src/library/manga'; +import type { HttpJsonFn, HttpTextFn, MediaTypeSpec, SpecDeps } from 'packages/obsidian/src/library/types'; +import type MediaDbPlugin from 'packages/obsidian/src/main'; +import { obsidianFetch } from 'packages/obsidian/src/utils/Utils'; +import { TmdbRateLimitError } from 'packages/obsidian/src/watchlist/SyncEngine'; +import { shouldNotifySync } from 'packages/obsidian/src/watchlist/WatchlistController'; + +/** Type <-> command-slug map, used by main.ts to register per-type commands and by the controller to iterate specs. */ +export const LIBRARY_TYPES: readonly { spec: MediaTypeSpec; slug: string }[] = [ + { spec: mangaSpec, slug: 'mangas' }, + { spec: bookSpec, slug: 'books' }, + { spec: gameSpec, slug: 'games' }, + { spec: comicSpec, slug: 'comics' }, +]; + +/** Specs whose enrichment needs a secretStorage-held API key (jikan/mangadex/openlibrary/rss/steam are keyless). */ +const KEY_REQUIREMENT: Partial> = { + game: 'rawg', + comic: 'comicvine', +}; + +const KEY_LABEL: Record<'rawg' | 'comicvine', string> = { + rawg: 'RAWG', + comicvine: 'Comic Vine', +}; + +function emptyReport(): LibraryReport { + return { scanned: 0, synced: 0, written: 0, skippedNoId: 0, skippedStatic: 0, skippedNoData: 0, flipped: [], errors: [] }; +} + +function emptyResolveReport(): LibraryResolveReport { + return { resolved: [], ambiguous: [], errors: [] }; +} + +export class LibraryController { + private syncing = false; + private specs: MediaTypeSpec[] = LIBRARY_TYPES.map(t => t.spec); + + constructor(private plugin: MediaDbPlugin) {} + + private notify(msg: string): void { + new Notice(msg); + } + + private getKey(name: 'rawg' | 'comicvine'): string { + const keyId = name === 'rawg' ? this.plugin.settings.RAWGAPIKeyId : this.plugin.settings.ComicVineKeyId; + const key = keyId ? this.plugin.app.secretStorage.getSecret(keyId) : null; + return key ?? ''; + } + + private folderFor(spec: MediaTypeSpec): string { + return (this.plugin.settings as unknown as Record)[spec.folderSettingKey]; + } + + private enabledFor(spec: MediaTypeSpec): boolean { + return (this.plugin.settings as unknown as Record)[spec.enabledSettingKey]; + } + + // Duplicated (not extracted) from WatchlistController.makeHttp -- watchlist code stays untouched per plan. + // Kept minimal + generic (no TMDB-specific wording) since it's shared across all library APIs. + private makeHttp(): HttpJsonFn { + return async (url: string, headers: Record): Promise => { + const res = await obsidianFetch(new Request(url, { headers })); + if (res.status === 429) { + const err = new TmdbRateLimitError(`Library API 429 for ${url}`); + const ra = Number(res.headers.get('retry-after')); + err.retryAfterMs = Number.isFinite(ra) && ra > 0 ? ra * 1000 : 2000; + throw err; + } + if (res.status !== 200) throw new Error(`Library API ${res.status} for ${url}`); + return await res.json(); + }; + } + + private makeHttpText(): HttpTextFn { + return async (url: string, headers: Record): Promise => { + const res = await obsidianFetch(new Request(url, { headers })); + if (res.status === 429) { + const err = new TmdbRateLimitError(`Library API 429 for ${url}`); + const ra = Number(res.headers.get('retry-after')); + err.retryAfterMs = Number.isFinite(ra) && ra > 0 ? ra * 1000 : 2000; + throw err; + } + if (res.status !== 200) throw new Error(`Library API ${res.status} for ${url}`); + return await res.text(); + }; + } + + private makeSpecDeps(): SpecDeps { + return { + http: this.makeHttp(), + httpText: this.makeHttpText(), + getKey: name => this.getKey(name), + log: msg => console.log(`[media-db-library] ${msg}`), + notify: msg => this.notify(msg), + }; + } + + private makeDeps(spec: MediaTypeSpec): LibraryEngineDeps { + const { app } = this.plugin; + const folderPath = this.folderFor(spec); + const folder = app.vault.getAbstractFileByPath(folderPath); + return { + listNotes: async () => { + if (!(folder instanceof TFolder)) throw new Error(`Library folder not found: ${folderPath}`); + const files = folder.children.filter((f): f is TFile => f instanceof TFile && f.extension === 'md' && !f.name.startsWith('_')); + return files.map(f => ({ path: f.path })); + }, + readNote: async path => { + const f = app.vault.getAbstractFileByPath(path); + if (!(f instanceof TFile)) throw new Error(`Library note not found: ${path}`); + return await app.vault.read(f); + }, + writeNote: async (path, content) => { + const f = app.vault.getAbstractFileByPath(path); + if (f instanceof TFile) await app.vault.modify(f, content); + }, + sleep: ms => new Promise(r => setTimeout(r, ms)), + log: msg => console.log(`[media-db-library] ${msg}`), + specDeps: this.makeSpecDeps(), + }; + } + + /** Per-note key skipping already happens inside the spec (log+skip); this only surfaces one Notice per sync run naming the affected type. */ + private warnMissingKey(spec: MediaTypeSpec): void { + const req = KEY_REQUIREMENT[spec.typeName]; + if (!req || this.getKey(req)) return; + this.notify(`Library sync: ${KEY_LABEL[req]} API key not configured — ${spec.typeName} enrichment skipped (Media DB Sync settings).`); + } + + private async runSync(spec: MediaTypeSpec, full: boolean, dryRun: boolean, quiet: boolean): Promise { + this.warnMissingKey(spec); + const deps = this.makeDeps(spec); + const report = await libraryFolderSync(spec, deps, { full, dryRun }); + const mode = dryRun ? 'DRY-RUN ' : ''; + if (shouldNotifySync(quiet, report.written, report.errors.length)) { + this.notify( + `Library ${mode}sync (${spec.typeName}): ${report.synced} checked, ${report.written} updated, ${report.flipped.length} flipped` + + (report.errors.length ? `, ${report.errors.length} errors (see console)` : ''), + ); + } + return report; + } + + async syncType(spec: MediaTypeSpec, full: boolean, dryRun = false, quiet = false): Promise { + if (this.syncing) { + this.notify('Library sync already running'); + return emptyReport(); + } + this.syncing = true; + try { + return await this.runSync(spec, full, dryRun, quiet); + } finally { + this.syncing = false; + } + } + + async resolveType(spec: MediaTypeSpec, dryRun = false): Promise { + if (this.syncing) { + this.notify('Library sync already running'); + return emptyResolveReport(); + } + this.syncing = true; + try { + const deps = this.makeDeps(spec); + const report = await libraryFolderResolve(spec, deps, { dryRun }); + const mode = dryRun ? 'DRY-RUN ' : ''; + this.notify( + `Library ${mode}resolve (${spec.typeName}): ${report.resolved.length} resolved, ${report.ambiguous.length} ambiguous/no match` + + (report.errors.length ? `, ${report.errors.length} errors (see console)` : ''), + ); + return report; + } finally { + this.syncing = false; + } + } + + async syncAll(full: boolean, quiet = false): Promise { + if (this.syncing) { + this.notify('Library sync already running'); + return; + } + this.syncing = true; + try { + for (const spec of this.specs) { + if (!this.enabledFor(spec)) continue; + await this.runSync(spec, full, false, quiet); + } + if (!full) { + this.plugin.settings.libraryLastSync = Date.now(); + await this.plugin.saveSettings(); + } + } finally { + this.syncing = false; + } + } + + async maybeCatchUp(): Promise { + const s = this.plugin.settings; + if (!this.specs.some(spec => this.enabledFor(spec))) return; + const due = s.libraryLastSync + s.watchlistSyncIntervalHours * 3600_000; + if (Date.now() >= due) { + await this.syncAll(false, true).catch(e => console.error('[media-db-library] scheduled sync failed', e)); + } + } +} diff --git a/packages/obsidian/src/main.ts b/packages/obsidian/src/main.ts index b80d72d..3c2d6da 100644 --- a/packages/obsidian/src/main.ts +++ b/packages/obsidian/src/main.ts @@ -16,6 +16,7 @@ import { TMDBSeasonAPI } from 'packages/obsidian/src/api/apis/TMDBSeasonAPI'; import { TMDBSeriesAPI } from 'packages/obsidian/src/api/apis/TMDBSeriesAPI'; import { VNDBAPI } from 'packages/obsidian/src/api/apis/VNDBAPI'; import { WikipediaAPI } from 'packages/obsidian/src/api/apis/WikipediaAPI'; +import { LibraryController, LIBRARY_TYPES } from 'packages/obsidian/src/library/LibraryController'; import type { LegacyApiKeyEntry } from 'packages/obsidian/src/modals/LegacyApiKeysModal'; import { LegacyApiKeysModal } from 'packages/obsidian/src/modals/LegacyApiKeysModal'; import { PropertyMapper } from 'packages/obsidian/src/settings/PropertyMapper'; @@ -46,6 +47,7 @@ export default class MediaDbPlugin extends Plugin { dateFormatter!: DateFormatter; errorReporter!: ErrorReporter; watchlist!: WatchlistController; + library!: LibraryController; async onload(): Promise { this.mediaTypeManager = new MediaTypeManager(); @@ -64,10 +66,13 @@ export default class MediaDbPlugin extends Plugin { this.registerCommands(); this.watchlist = new WatchlistController(this); - // catch-up shortly after startup (let vault index settle), then hourly due-check - const catchUpTimeout = window.setTimeout(() => void this.watchlist.maybeCatchUp(), 30_000); + this.library = new LibraryController(this); + // catch-up shortly after startup (let vault index settle), then hourly due-check. + // library catch-up runs after watchlist's, sharing the same timers (no new timers). + const catchUp = () => void this.watchlist.maybeCatchUp().then(() => this.library.maybeCatchUp()); + const catchUpTimeout = window.setTimeout(catchUp, 30_000); this.register(() => window.clearTimeout(catchUpTimeout)); - this.registerInterval(window.setInterval(() => void this.watchlist.maybeCatchUp(), 3600_000)); + this.registerInterval(window.setInterval(catchUp, 3600_000)); } onunload(): void {} @@ -197,6 +202,29 @@ export default class MediaDbPlugin extends Plugin { name: 'Watchlist: resolve missing TMDB ids', callback: () => void this.watchlist.resolveMissingIds().catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))), }); + + for (const { spec, slug } of LIBRARY_TYPES) { + this.addCommand({ + id: `library-sync-${slug}`, + name: `Library: sync ${slug} now`, + callback: () => void this.library.syncType(spec, false).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))), + }); + this.addCommand({ + id: `library-resolve-${slug}`, + name: `Library: resolve ${slug} ids`, + callback: () => void this.library.resolveType(spec).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))), + }); + this.addCommand({ + id: `library-dry-run-${slug}`, + name: `Library: dry-run ${slug} full sync`, + callback: () => void this.library.syncType(spec, true, true).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))), + }); + } + this.addCommand({ + id: 'library-sync-all', + name: 'Library: sync all', + callback: () => void this.library.syncAll(false).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))), + }); } private getLegacyApiKeyEntries(diskSettings: Record): LegacyApiKeyEntry[] { diff --git a/packages/obsidian/src/settings/Settings.ts b/packages/obsidian/src/settings/Settings.ts index 06554f0..034e93d 100644 --- a/packages/obsidian/src/settings/Settings.ts +++ b/packages/obsidian/src/settings/Settings.ts @@ -124,6 +124,16 @@ export interface MediaDbPluginSettings { watchlistSyncIntervalHours: number; watchlistLastSync: number; + libraryMangaEnabled: boolean; + libraryMangaFolder: string; + libraryBookEnabled: boolean; + libraryBookFolder: string; + libraryGameEnabled: boolean; + libraryGameFolder: string; + libraryComicEnabled: boolean; + libraryComicFolder: string; + libraryLastSync: number; + // DEPRECATED: Use propertyMappingModels instead moviePropertyConversionRules: string; seriesPropertyConversionRules: string; @@ -388,6 +398,16 @@ const DEFAULT_SETTINGS: MediaDbPluginSettings = { watchlistSyncIntervalHours: 24, watchlistLastSync: 0, + libraryMangaEnabled: false, + libraryMangaFolder: 'Mangas', + libraryBookEnabled: false, + libraryBookFolder: 'Books', + libraryGameEnabled: false, + libraryGameFolder: 'Games', + libraryComicEnabled: false, + libraryComicFolder: 'Comics', + libraryLastSync: 0, + // DEPRECATED moviePropertyConversionRules: '', seriesPropertyConversionRules: '', @@ -791,6 +811,54 @@ export class MediaDbSettingTab extends PluginSettingTab { }), ); + // MARK: Library sync + const libraryGroup = new SettingGroup(containerEl); + libraryGroup.setHeading('Library sync'); + + const libraryTypeSettings: { label: string; enabledKey: keyof MediaDbPluginSettings; folderKey: keyof MediaDbPluginSettings; desc: string }[] = [ + { label: 'Manga', enabledKey: 'libraryMangaEnabled', folderKey: 'libraryMangaFolder', desc: 'Jikan (MyAnimeList) + MangaDex/RSS chapter tracking.' }, + { label: 'Book', enabledKey: 'libraryBookEnabled', folderKey: 'libraryBookFolder', desc: 'Open Library enrichment.' }, + { label: 'Game', enabledKey: 'libraryGameEnabled', folderKey: 'libraryGameFolder', desc: 'Steam + RAWG enrichment. Uses the RAWG API key configured above.' }, + { label: 'Comic', enabledKey: 'libraryComicEnabled', folderKey: 'libraryComicFolder', desc: 'Comic Vine enrichment. Uses the Comic Vine API key configured above.' }, + ]; + + for (const { label, enabledKey, folderKey, desc } of libraryTypeSettings) { + libraryGroup.addSetting( + setting => + void setting + .setName(`Enable ${label.toLowerCase()} library sync`) + .setDesc(`Periodically sync ${label.toLowerCase()} notes against their source APIs. ${desc}`) + .addToggle(cb => { + cb.setValue(this.plugin.settings[enabledKey] as boolean).onChange(data => { + (this.plugin.settings[enabledKey] as boolean) = data; + void this.plugin.saveSettings(); + }); + }), + ); + + libraryGroup.addSetting( + setting => + void setting + .setName(`${label} folder`) + .setDesc(`Folder containing ${label.toLowerCase()} notes.`) + .addSearch(cb => { + const suggester = new FolderSuggest(this.app, cb.inputEl); + suggester.onSelect(folder => { + cb.setValue(folder.path); + (this.plugin.settings[folderKey] as string) = folder.path; + void this.plugin.saveSettings(); + suggester.close(); + }); + cb.setPlaceholder(DEFAULT_SETTINGS[folderKey] as string) + .setValue(this.plugin.settings[folderKey] as string) + .onChange(data => { + (this.plugin.settings[folderKey] as string) = data; + void this.plugin.saveSettings(); + }); + }), + ); + } + // MARK: Media type settings // Create a map to store APIs for each media type diff --git a/tests/library-controller.test.ts b/tests/library-controller.test.ts new file mode 100644 index 0000000..8f9cbfa --- /dev/null +++ b/tests/library-controller.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, test } from 'bun:test'; +import { bookSpec } from 'packages/obsidian/src/library/book'; +import { gameSpec } from 'packages/obsidian/src/library/game'; +import { LibraryController } from 'packages/obsidian/src/library/LibraryController'; +import type { LibraryEngineDeps } from 'packages/obsidian/src/library/LibraryEngine'; +import { mangaSpec } from 'packages/obsidian/src/library/manga'; + +function fakePlugin( + overrides: Partial<{ + mangaEnabled: boolean; + bookEnabled: boolean; + gameEnabled: boolean; + comicEnabled: boolean; + last: number; + hours: number; + rawgKeyId: string; + comicvineKeyId: string; + }> = {}, +) { + const secrets: Record = {}; + if (overrides.rawgKeyId) secrets[overrides.rawgKeyId] = 'rawg-secret'; + if (overrides.comicvineKeyId) secrets[overrides.comicvineKeyId] = 'cv-secret'; + return { + settings: { + libraryMangaEnabled: overrides.mangaEnabled ?? true, + libraryMangaFolder: 'Mangas', + libraryBookEnabled: overrides.bookEnabled ?? true, + libraryBookFolder: 'Books', + libraryGameEnabled: overrides.gameEnabled ?? true, + libraryGameFolder: 'Games', + libraryComicEnabled: overrides.comicEnabled ?? true, + libraryComicFolder: 'Comics', + libraryLastSync: overrides.last ?? 0, + watchlistSyncIntervalHours: overrides.hours ?? 24, + RAWGAPIKeyId: overrides.rawgKeyId ?? '', + ComicVineKeyId: overrides.comicvineKeyId ?? '', + }, + saveSettings: async () => {}, + app: { + secretStorage: { getSecret: (id: string) => secrets[id] ?? null }, + vault: {}, + }, + } as any; +} + +describe('maybeCatchUp', () => { + test('overdue → syncs', async () => { + const c = new LibraryController(fakePlugin({ last: 0 })); + let called = false; + (c as any).syncAll = async () => { + called = true; + }; + await c.maybeCatchUp(); + expect(called).toBe(true); + }); + + test('recent sync → no call', async () => { + const c = new LibraryController(fakePlugin({ last: Date.now() })); + let called = false; + (c as any).syncAll = async () => { + called = true; + }; + await c.maybeCatchUp(); + expect(called).toBe(false); + }); + + test('all types disabled → no call', async () => { + const c = new LibraryController(fakePlugin({ mangaEnabled: false, bookEnabled: false, gameEnabled: false, comicEnabled: false, last: 0 })); + let called = false; + (c as any).syncAll = async () => { + called = true; + }; + await c.maybeCatchUp(); + expect(called).toBe(false); + }); +}); + +function fakeSpecDeps() { + return { http: async () => ({}), httpText: async () => '', getKey: () => '', log: () => {}, notify: () => {} }; +} + +function deferredDeps(): { deps: LibraryEngineDeps; listNotesCalls: () => number; release: () => void } { + let listNotesCalls = 0; + let release!: () => void; + const gate = new Promise(resolve => { + release = resolve; + }); + const deps: LibraryEngineDeps = { + listNotes: async () => { + listNotesCalls++; + await gate; + return []; + }, + readNote: async () => '', + writeNote: async () => {}, + sleep: async () => {}, + log: () => {}, + specDeps: fakeSpecDeps(), + }; + return { deps, listNotesCalls: () => listNotesCalls, release }; +} + +describe('concurrency guard', () => { + test('overlapping syncType calls: second short-circuits while first is in flight', async () => { + const c = new LibraryController(fakePlugin()); + const { deps, listNotesCalls, release } = deferredDeps(); + (c as any).makeDeps = () => deps; + + const first = c.syncType(mangaSpec, false); + const second = await c.syncType(mangaSpec, false); + + expect(listNotesCalls()).toBe(1); + expect(second.scanned).toBe(0); + expect(second.written).toBe(0); + + release(); + const firstResult = await first; + expect(firstResult.scanned).toBe(0); + }); + + test('syncType in flight blocks resolveType (shared guard)', async () => { + const c = new LibraryController(fakePlugin()); + const { deps, release } = deferredDeps(); + (c as any).makeDeps = () => deps; + + const first = c.syncType(mangaSpec, false); + const resolveResult = await c.resolveType(bookSpec); + + expect(resolveResult.resolved.length).toBe(0); + expect(resolveResult.ambiguous.length).toBe(0); + + release(); + await first; + }); + + test('syncType in flight blocks syncAll (shared guard)', async () => { + const c = new LibraryController(fakePlugin()); + const { deps, release } = deferredDeps(); + (c as any).makeDeps = () => deps; + + const first = c.syncType(mangaSpec, false); + await c.syncAll(false, true); // should short-circuit, not throw + + release(); + await first; + }); + + test('flag resets after completion → next call runs normally', async () => { + const c = new LibraryController(fakePlugin()); + let listNotesCalls = 0; + (c as any).makeDeps = () => ({ + listNotes: async () => { + listNotesCalls++; + return []; + }, + readNote: async () => '', + writeNote: async () => {}, + sleep: async () => {}, + log: () => {}, + specDeps: fakeSpecDeps(), + }); + + await c.syncType(mangaSpec, false); + await c.syncType(mangaSpec, false); + + expect(listNotesCalls).toBe(2); + }); +}); + +describe('missing API key handling', () => { + test('missing RAWG + Comic Vine keys → one notice each for game/comic, manga/book unaffected', async () => { + const c = new LibraryController(fakePlugin()); // no rawgKeyId/comicvineKeyId configured + const notices: string[] = []; + (c as any).notify = (msg: string) => notices.push(msg); + const calledTypes: string[] = []; + (c as any).makeDeps = (spec: { typeName: string }) => { + calledTypes.push(spec.typeName); + return { + listNotes: async () => [], + readNote: async () => '', + writeNote: async () => {}, + sleep: async () => {}, + log: () => {}, + specDeps: fakeSpecDeps(), + }; + }; + + await c.syncAll(false, true); + + expect(calledTypes.sort()).toEqual(['book', 'comic', 'game', 'manga']); + const keyNotices = notices.filter(m => m.includes('API key not configured')); + expect(keyNotices.length).toBe(2); + expect(keyNotices.some(m => m.includes('RAWG') && m.includes('game'))).toBe(true); + expect(keyNotices.some(m => m.includes('Comic Vine') && m.includes('comic'))).toBe(true); + }); + + test('keys present → no missing-key notices', async () => { + const c = new LibraryController(fakePlugin({ rawgKeyId: 'rid', comicvineKeyId: 'cvid' })); + const notices: string[] = []; + (c as any).notify = (msg: string) => notices.push(msg); + (c as any).makeDeps = () => ({ + listNotes: async () => [], + readNote: async () => '', + writeNote: async () => {}, + sleep: async () => {}, + log: () => {}, + specDeps: fakeSpecDeps(), + }); + + await c.syncAll(false, true); + + expect(notices.filter(m => m.includes('API key not configured')).length).toBe(0); + }); + + test('single-type sync (game) with missing RAWG key still notifies once and runs', async () => { + const c = new LibraryController(fakePlugin({ rawgKeyId: '' })); + const notices: string[] = []; + (c as any).notify = (msg: string) => notices.push(msg); + let ran = false; + (c as any).makeDeps = () => { + ran = true; + return { + listNotes: async () => [], + readNote: async () => '', + writeNote: async () => {}, + sleep: async () => {}, + log: () => {}, + specDeps: fakeSpecDeps(), + }; + }; + + await c.syncType(gameSpec, false); + + expect(ran).toBe(true); + expect(notices.filter(m => m.includes('API key not configured')).length).toBe(1); + }); +});