feat(watchlist): settings, commands, catch-up scheduler wiring
This commit is contained in:
parent
e8cb9b93fe
commit
f9cc324ead
4 changed files with 203 additions and 0 deletions
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
74
packages/obsidian/src/watchlist/WatchlistController.ts
Normal file
74
packages/obsidian/src/watchlist/WatchlistController.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
40
tests/watchlist-controller.test.ts
Normal file
40
tests/watchlist-controller.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue