feat(watchlist): resolve-missing-ids command

This commit is contained in:
afiqzudinhadi 2026-07-30 12:54:49 +08:00
parent aafa3bb1e4
commit 22a409ce9b
4 changed files with 156 additions and 15 deletions

View file

@ -191,6 +191,11 @@ export default class MediaDbPlugin extends Plugin {
name: 'Watchlist: dry-run full sync (log only, no writes)', name: 'Watchlist: dry-run full sync (log only, no writes)',
callback: () => void this.watchlist.syncNow(true, true).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))), callback: () => void this.watchlist.syncNow(true, true).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))),
}); });
this.addCommand({
id: 'watchlist-resolve-ids',
name: 'Watchlist: resolve missing TMDB ids',
callback: () => void this.watchlist.resolveMissingIds().catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))),
});
} }
private getLegacyApiKeyEntries(diskSettings: Record<string, unknown>): LegacyApiKeyEntry[] { private getLegacyApiKeyEntries(diskSettings: Record<string, unknown>): LegacyApiKeyEntry[] {

View file

@ -1,8 +1,29 @@
import { Notice, TFile, TFolder } from 'obsidian'; import { Notice, TFile, TFolder } from 'obsidian';
import type MediaDbPlugin from 'packages/obsidian/src/main'; import type MediaDbPlugin from 'packages/obsidian/src/main';
import { syncFolder, TmdbRateLimitError, type SyncDeps, type SyncReport } from 'packages/obsidian/src/watchlist/SyncEngine'; import { syncFolder, TmdbRateLimitError, type SyncDeps, type SyncReport } from 'packages/obsidian/src/watchlist/SyncEngine';
import { fetchDetail } from 'packages/obsidian/src/watchlist/tmdb'; import { fetchDetail, searchTitle, type HttpJsonFn } from 'packages/obsidian/src/watchlist/tmdb';
import { obsidianFetch } from 'packages/obsidian/src/utils/Utils'; import { obsidianFetch } from 'packages/obsidian/src/utils/Utils';
import { parseNote, noteTmdbRef, stripQuotes } from 'packages/obsidian/src/watchlist/parse';
import { resolveNote } from 'packages/obsidian/src/watchlist/resolve';
export interface ResolveReport {
scanned: number;
resolved: number;
ambiguous: number;
errors: { path: string; error: string }[];
}
function patchFrontmatter(content: string, tmdbId: string, mediaType: string): string {
const insert = `tmdb_id: ${tmdbId}\nmedia_type: ${mediaType}`;
const fmMatch = /^---\n([\s\S]*?)\n---/.exec(content);
if (fmMatch) {
const inner = fmMatch[1];
const typeLine = /^type:.*$/m.exec(inner);
const newInner = typeLine ? inner.slice(0, typeLine.index + typeLine[0].length) + '\n' + insert + inner.slice(typeLine.index + typeLine[0].length) : insert + '\n' + inner;
return content.slice(0, fmMatch.index) + '---\n' + newInner + '\n---' + content.slice(fmMatch.index + fmMatch[0].length);
}
return `---\ntype: watchlist_item\n${insert}\n---\n\n` + content;
}
export class WatchlistController { export class WatchlistController {
constructor(private plugin: MediaDbPlugin) {} constructor(private plugin: MediaDbPlugin) {}
@ -14,9 +35,24 @@ export class WatchlistController {
return key; return key;
} }
private makeHttp(): HttpJsonFn {
return 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();
};
}
private makeDeps(key: string): SyncDeps { private makeDeps(key: string): SyncDeps {
const { app } = this.plugin; const { app } = this.plugin;
const folder = app.vault.getAbstractFileByPath(this.plugin.settings.watchlistFolder); const folder = app.vault.getAbstractFileByPath(this.plugin.settings.watchlistFolder);
const http = this.makeHttp();
return { return {
listNotes: async () => { listNotes: async () => {
if (!(folder instanceof TFolder)) throw new Error(`Watchlist folder not found: ${this.plugin.settings.watchlistFolder}`); if (!(folder instanceof TFolder)) throw new Error(`Watchlist folder not found: ${this.plugin.settings.watchlistFolder}`);
@ -29,20 +65,7 @@ export class WatchlistController {
const f = app.vault.getAbstractFileByPath(path); const f = app.vault.getAbstractFileByPath(path);
if (f instanceof TFile) await app.vault.modify(f, content); if (f instanceof TFile) await app.vault.modify(f, content);
}, },
fetchDetail: async (tmdbId, isMovie) => { fetchDetail: async (tmdbId, isMovie) => await fetchDetail(http, key, 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)), sleep: ms => new Promise(r => setTimeout(r, ms)),
log: msg => console.log(`[media-db-sync] ${msg}`), log: msg => console.log(`[media-db-sync] ${msg}`),
}; };
@ -71,4 +94,44 @@ export class WatchlistController {
await this.syncNow(false).catch(e => console.error('[media-db-sync] scheduled sync failed', e)); await this.syncNow(false).catch(e => console.error('[media-db-sync] scheduled sync failed', e));
} }
} }
async resolveMissingIds(dryRun = false): Promise<ResolveReport> {
const key = this.getKey();
const deps = this.makeDeps(key);
const http = this.makeHttp();
const search = (q: string, isMovie: boolean, year?: string) => searchTitle(http, key, q, isMovie, year);
const report: ResolveReport = { scanned: 0, resolved: 0, ambiguous: 0, errors: [] };
const notes = await deps.listNotes();
for (const note of notes) {
const { frontmatter } = parseNote(note.content);
if (noteTmdbRef(frontmatter)) continue;
const type = stripQuotes(frontmatter['type']);
if (type && type !== 'watchlist_item') continue; // skip type: list dashboards etc.
report.scanned++;
try {
const filename = note.path.split('/').pop() ?? note.path;
const result = await resolveNote(frontmatter, filename, search);
if (!result) {
report.ambiguous++;
deps.log(`ambiguous/no match: ${note.path}`);
} else {
report.resolved++;
const mediaType = result.isMovie ? 'Movie' : 'TV Series';
deps.log(`${dryRun ? '[dry] ' : ''}resolved ${note.path} -> tmdb_id ${result.tmdbId} (${result.matchedTitle})`);
if (!dryRun) await deps.writeNote(note.path, patchFrontmatter(note.content, result.tmdbId, mediaType));
}
await deps.sleep(250);
} catch (e) {
report.errors.push({ path: note.path, error: e instanceof Error ? e.message : String(e) });
deps.log(`ERROR ${note.path}: ${String(e)}`);
}
}
const mode = dryRun ? 'DRY-RUN ' : '';
new Notice(
`Watchlist ${mode}resolve: ${report.scanned} scanned, ${report.resolved} resolved, ${report.ambiguous} ambiguous/no match` +
(report.errors.length ? `, ${report.errors.length} errors (see console)` : ''),
);
return report;
}
} }

View file

@ -0,0 +1,36 @@
type SearchFn = (query: string, isMovie: boolean, year?: string) => Promise<any[]>;
export interface ResolveResult {
tmdbId: string;
isMovie: boolean;
matchedTitle: string;
}
function resultTitle(r: any): string {
return r.title ?? r.name ?? '';
}
function resultTitles(r: any): string[] {
return [r.title, r.original_title, r.name, r.original_name].filter(Boolean).map((t: string) => t.toLowerCase());
}
async function tryOne(query: string, isMovie: boolean, year: string | undefined, search: SearchFn): Promise<ResolveResult | null> {
const results = await search(query, isMovie, year);
if (results.length === 0) return null;
const q = query.toLowerCase();
const exact = results.find(r => resultTitles(r).includes(q));
const pick = exact ?? (results.length === 1 ? results[0] : null);
if (!pick) return null;
return { tmdbId: String(pick.id), isMovie, matchedTitle: resultTitle(pick) };
}
export async function resolveNote(fm: Record<string, string>, filename: string, search: SearchFn): Promise<ResolveResult | null> {
const strip = (s: string | undefined): string => (s ?? '').trim().replace(/^"|"$/g, '');
const query = strip(fm['title']) || filename.replace(/\.md$/, '');
const yearField = strip(fm['year']) || strip(fm['release_date']);
const year = /^\d{4}/.exec(yearField)?.[0];
const mt = strip(fm['media_type']);
if (mt === 'Movie') return await tryOne(query, true, year, search);
if (mt) return await tryOne(query, false, year, search);
return (await tryOne(query, true, year, search)) ?? (await tryOne(query, false, year, search));
}

View file

@ -0,0 +1,37 @@
import { describe, expect, test } from 'bun:test';
import { resolveNote } from 'packages/obsidian/src/watchlist/resolve';
const HIT = { id: 693134, title: 'Dune: Part Two', original_title: 'Dune: Part Two' };
const OTHER = { id: 1, title: 'Dune', original_title: 'Dune' };
describe('resolveNote', () => {
test('exact title match accepted', async () => {
const r = await resolveNote({ media_type: 'Movie' }, 'Dune: Part Two.md', async () => [HIT, OTHER]);
expect(r).toEqual({ tmdbId: '693134', isMovie: true, matchedTitle: 'Dune: Part Two' });
});
test('single result accepted even if inexact', async () => {
const r = await resolveNote({ media_type: 'Movie' }, 'Dune Part 2.md', async () => [HIT]);
expect(r?.tmdbId).toBe('693134');
});
test('ambiguous → null', async () => {
const r = await resolveNote({ media_type: 'Movie' }, 'Dune something.md', async () => [HIT, OTHER]);
expect(r).toBeNull();
});
test('no media_type → movie then tv fallback', async () => {
const calls: boolean[] = [];
const r = await resolveNote({}, 'Loki.md', async (q, isMovie) => {
calls.push(isMovie);
return isMovie ? [] : [{ id: 84958, name: 'Loki', original_name: 'Loki' }];
});
expect(calls).toEqual([true, false]);
expect(r).toEqual({ tmdbId: '84958', isMovie: false, matchedTitle: 'Loki' });
});
test('year hint passed through', async () => {
let seenYear: string | undefined;
await resolveNote({ media_type: 'Movie', year: '2024' }, 'Dune: Part Two.md', async (q, m, year) => {
seenYear = year;
return [HIT];
});
expect(seenYear).toBe('2024');
});
});