feat(watchlist): settings, commands, catch-up scheduler wiring

This commit is contained in:
afiqzudinhadi 2026-07-30 12:44:52 +08:00
parent e8cb9b93fe
commit f9cc324ead
4 changed files with 203 additions and 0 deletions

View file

@ -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<void> {
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<string, unknown>): LegacyApiKeyEntry[] {

View file

@ -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 (1168).')
.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

View file

@ -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<string, string>): Promise<any> => {
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<SyncReport> {
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<void> {
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));
}
}
}

View file

@ -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);
});
});