From f9cc324ead90e17211264a6898305d7d3801fdc8 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Thu, 30 Jul 2026 12:44:52 +0800 Subject: [PATCH] feat(watchlist): settings, commands, catch-up scheduler wiring --- packages/obsidian/src/main.ts | 23 ++++++ packages/obsidian/src/settings/Settings.ts | 66 +++++++++++++++++ .../src/watchlist/WatchlistController.ts | 74 +++++++++++++++++++ tests/watchlist-controller.test.ts | 40 ++++++++++ 4 files changed, 203 insertions(+) create mode 100644 packages/obsidian/src/watchlist/WatchlistController.ts create mode 100644 tests/watchlist-controller.test.ts diff --git a/packages/obsidian/src/main.ts b/packages/obsidian/src/main.ts index 9d3be8b..020a958 100644 --- a/packages/obsidian/src/main.ts +++ b/packages/obsidian/src/main.ts @@ -32,6 +32,7 @@ import { MediaTypeManager } from 'packages/obsidian/src/utils/MediaTypeManager'; import { MEDIA_TYPES } from 'packages/obsidian/src/utils/MediaTypeManager'; import { ModalHelper } from 'packages/obsidian/src/utils/ModalHelper'; import { unCamelCase } from 'packages/obsidian/src/utils/Utils'; +import { WatchlistController } from 'packages/obsidian/src/watchlist/WatchlistController'; export default class MediaDbPlugin extends Plugin { declare settings: MediaDbPluginSettings; @@ -44,6 +45,7 @@ export default class MediaDbPlugin extends Plugin { bulkImportHelper!: BulkImportHelper; dateFormatter!: DateFormatter; errorReporter!: ErrorReporter; + watchlist!: WatchlistController; async onload(): Promise { this.mediaTypeManager = new MediaTypeManager(); @@ -60,6 +62,11 @@ export default class MediaDbPlugin extends Plugin { this.addSettingTab(new MediaDbSettingTab(this.app, this)); this.registerRibbonAndFileMenu(); this.registerCommands(); + + this.watchlist = new WatchlistController(this); + // catch-up shortly after startup (let vault index settle), then hourly due-check + window.setTimeout(() => void this.watchlist.maybeCatchUp(), 30_000); + this.registerInterval(window.setInterval(() => void this.watchlist.maybeCatchUp(), 3600_000)); } onunload(): void {} @@ -168,6 +175,22 @@ export default class MediaDbPlugin extends Plugin { return true; }, }); + + this.addCommand({ + id: 'watchlist-sync-now', + name: 'Watchlist: sync now (airing/active only)', + callback: () => void this.watchlist.syncNow(false), + }); + this.addCommand({ + id: 'watchlist-sync-full', + name: 'Watchlist: full sync (all entries)', + callback: () => void this.watchlist.syncNow(true), + }); + this.addCommand({ + id: 'watchlist-sync-dry-run', + name: 'Watchlist: dry-run full sync (log only, no writes)', + callback: () => void this.watchlist.syncNow(true, true), + }); } private getLegacyApiKeyEntries(diskSettings: Record): LegacyApiKeyEntry[] { diff --git a/packages/obsidian/src/settings/Settings.ts b/packages/obsidian/src/settings/Settings.ts index 9f54d7c..06554f0 100644 --- a/packages/obsidian/src/settings/Settings.ts +++ b/packages/obsidian/src/settings/Settings.ts @@ -119,6 +119,11 @@ export interface MediaDbPluginSettings { propertyMappingModels: PropertyMappingModelData[]; + watchlistEnabled: boolean; + watchlistFolder: string; + watchlistSyncIntervalHours: number; + watchlistLastSync: number; + // DEPRECATED: Use propertyMappingModels instead moviePropertyConversionRules: string; seriesPropertyConversionRules: string; @@ -378,6 +383,11 @@ const DEFAULT_SETTINGS: MediaDbPluginSettings = { propertyMappingModels: [], + watchlistEnabled: false, + watchlistFolder: 'Watchlist', + watchlistSyncIntervalHours: 24, + watchlistLastSync: 0, + // DEPRECATED moviePropertyConversionRules: '', seriesPropertyConversionRules: '', @@ -725,6 +735,62 @@ export class MediaDbSettingTab extends PluginSettingTab { }), ); + // MARK: Watchlist sync + const watchlistGroup = new SettingGroup(containerEl); + watchlistGroup.setHeading('Watchlist sync'); + + watchlistGroup.addSetting( + setting => + void setting + .setName('Enable watchlist sync') + .setDesc('Periodically sync TV/movie watchlist notes against TMDB. Uses the TMDB API key configured above.') + .addToggle(cb => { + cb.setValue(this.plugin.settings.watchlistEnabled).onChange(data => { + this.plugin.settings.watchlistEnabled = data; + void this.plugin.saveSettings(); + }); + }), + ); + + watchlistGroup.addSetting( + setting => + void setting + .setName('Watchlist folder') + .setDesc('Folder containing watchlist notes.') + .addSearch(cb => { + const suggester = new FolderSuggest(this.app, cb.inputEl); + suggester.onSelect(folder => { + cb.setValue(folder.path); + this.plugin.settings.watchlistFolder = folder.path; + void this.plugin.saveSettings(); + suggester.close(); + }); + cb.setPlaceholder(DEFAULT_SETTINGS.watchlistFolder) + .setValue(this.plugin.settings.watchlistFolder) + .onChange(data => { + this.plugin.settings.watchlistFolder = data; + void this.plugin.saveSettings(); + }); + }), + ); + + watchlistGroup.addSetting( + setting => + void setting + .setName('Sync interval (hours)') + .setDesc('How often to auto-sync the watchlist, in hours (1–168).') + .addText(cb => { + cb.setPlaceholder(String(DEFAULT_SETTINGS.watchlistSyncIntervalHours)) + .setValue(String(this.plugin.settings.watchlistSyncIntervalHours)) + .onChange(data => { + const parsed = Math.min(168, Math.max(1, Math.round(Number(data)))); + if (!Number.isFinite(parsed)) return; + this.plugin.settings.watchlistSyncIntervalHours = parsed; + void this.plugin.saveSettings(); + }); + }), + ); + // MARK: Media type settings // Create a map to store APIs for each media type diff --git a/packages/obsidian/src/watchlist/WatchlistController.ts b/packages/obsidian/src/watchlist/WatchlistController.ts new file mode 100644 index 0000000..a80ef63 --- /dev/null +++ b/packages/obsidian/src/watchlist/WatchlistController.ts @@ -0,0 +1,74 @@ +import { Notice, TFile, TFolder } from 'obsidian'; +import type MediaDbPlugin from 'packages/obsidian/src/main'; +import { syncFolder, TmdbRateLimitError, type SyncDeps, type SyncReport } from 'packages/obsidian/src/watchlist/SyncEngine'; +import { fetchDetail } from 'packages/obsidian/src/watchlist/tmdb'; +import { obsidianFetch } from 'packages/obsidian/src/utils/Utils'; + +export class WatchlistController { + constructor(private plugin: MediaDbPlugin) {} + + private getKey(): string { + const keyId = this.plugin.settings.TMDBKeyId; + const key = keyId ? this.plugin.app.secretStorage.getSecret(keyId) : null; + if (!key) throw new Error('TMDB API key not configured (Media DB Sync settings).'); + return key; + } + + private makeDeps(key: string): SyncDeps { + const { app } = this.plugin; + const folder = app.vault.getAbstractFileByPath(this.plugin.settings.watchlistFolder); + return { + listNotes: async () => { + if (!(folder instanceof TFolder)) throw new Error(`Watchlist folder not found: ${this.plugin.settings.watchlistFolder}`); + const files = folder.children.filter((f): f is TFile => f instanceof TFile && f.extension === 'md' && !f.name.startsWith('_')); + const out: { path: string; content: string }[] = []; + for (const f of files) out.push({ path: f.path, content: await app.vault.read(f) }); + return out; + }, + writeNote: async (path, content) => { + const f = app.vault.getAbstractFileByPath(path); + if (f instanceof TFile) await app.vault.modify(f, content); + }, + fetchDetail: async (tmdbId, isMovie) => { + const http = async (url: string, headers: Record): Promise => { + const res = await obsidianFetch(new Request(url, { headers })); + if (res.status === 429) { + const err = new TmdbRateLimitError('TMDB 429'); + const ra = Number(res.headers.get('retry-after') ?? 2); + err.retryAfterMs = ra * 1000; + throw err; + } + if (res.status !== 200) throw new Error(`TMDB ${res.status} for ${url}`); + return await res.json(); + }; + return await fetchDetail(http, key, tmdbId, isMovie); + }, + sleep: ms => new Promise(r => setTimeout(r, ms)), + log: msg => console.log(`[media-db-sync] ${msg}`), + }; + } + + async syncNow(full: boolean, dryRun = false): Promise { + const key = this.getKey(); + const report = await syncFolder(this.makeDeps(key), { full, dryRun }); + const mode = dryRun ? 'DRY-RUN ' : ''; + new Notice( + `Watchlist ${mode}sync: ${report.synced} checked, ${report.written} updated, ${report.flipped.length} flipped to Unwatched` + + (report.errors.length ? `, ${report.errors.length} errors (see console)` : ''), + ); + if (!dryRun) { + this.plugin.settings.watchlistLastSync = Date.now(); + await this.plugin.saveSettings(); + } + return report; + } + + async maybeCatchUp(): Promise { + const s = this.plugin.settings; + if (!s.watchlistEnabled) return; + const due = s.watchlistLastSync + s.watchlistSyncIntervalHours * 3600_000; + if (Date.now() >= due) { + await this.syncNow(false).catch(e => console.error('[media-db-sync] scheduled sync failed', e)); + } + } +} diff --git a/tests/watchlist-controller.test.ts b/tests/watchlist-controller.test.ts new file mode 100644 index 0000000..e3efb79 --- /dev/null +++ b/tests/watchlist-controller.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'bun:test'; +import { WatchlistController } from 'packages/obsidian/src/watchlist/WatchlistController'; + +function fakePlugin(overrides: Partial<{ enabled: boolean; last: number; hours: number }> = {}) { + return { + settings: { + watchlistEnabled: overrides.enabled ?? true, + watchlistLastSync: overrides.last ?? 0, + watchlistSyncIntervalHours: overrides.hours ?? 24, + watchlistFolder: 'Watchlist', + TMDBKeyId: 'kid', + }, + saveSettings: async () => {}, + app: {}, + } as any; +} + +describe('maybeCatchUp', () => { + test('overdue → syncs', async () => { + const c = new WatchlistController(fakePlugin({ last: 0 })); + let called = false; + (c as any).syncNow = async () => { called = true; return {} as any; }; + await c.maybeCatchUp(); + expect(called).toBe(true); + }); + test('recent sync → no call', async () => { + const c = new WatchlistController(fakePlugin({ last: Date.now() })); + let called = false; + (c as any).syncNow = async () => { called = true; return {} as any; }; + await c.maybeCatchUp(); + expect(called).toBe(false); + }); + test('disabled → no call', async () => { + const c = new WatchlistController(fakePlugin({ enabled: false, last: 0 })); + let called = false; + (c as any).syncNow = async () => { called = true; return {} as any; }; + await c.maybeCatchUp(); + expect(called).toBe(false); + }); +});