diff --git a/packages/obsidian/src/main.ts b/packages/obsidian/src/main.ts index 0982547..8099844 100644 --- a/packages/obsidian/src/main.ts +++ b/packages/obsidian/src/main.ts @@ -191,6 +191,11 @@ export default class MediaDbPlugin extends Plugin { 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))), }); + 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): LegacyApiKeyEntry[] { diff --git a/packages/obsidian/src/watchlist/WatchlistController.ts b/packages/obsidian/src/watchlist/WatchlistController.ts index a80ef63..d2cc200 100644 --- a/packages/obsidian/src/watchlist/WatchlistController.ts +++ b/packages/obsidian/src/watchlist/WatchlistController.ts @@ -1,8 +1,29 @@ 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 { fetchDetail, searchTitle, type HttpJsonFn } from 'packages/obsidian/src/watchlist/tmdb'; 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 { constructor(private plugin: MediaDbPlugin) {} @@ -14,9 +35,24 @@ export class WatchlistController { return key; } + 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('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 { const { app } = this.plugin; const folder = app.vault.getAbstractFileByPath(this.plugin.settings.watchlistFolder); + const http = this.makeHttp(); return { listNotes: async () => { 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); 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); - }, + fetchDetail: async (tmdbId, isMovie) => await fetchDetail(http, key, tmdbId, isMovie), sleep: ms => new Promise(r => setTimeout(r, ms)), 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)); } } + + async resolveMissingIds(dryRun = false): Promise { + 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; + } } diff --git a/packages/obsidian/src/watchlist/resolve.ts b/packages/obsidian/src/watchlist/resolve.ts new file mode 100644 index 0000000..948fab9 --- /dev/null +++ b/packages/obsidian/src/watchlist/resolve.ts @@ -0,0 +1,36 @@ +type SearchFn = (query: string, isMovie: boolean, year?: string) => Promise; + +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 { + 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, filename: string, search: SearchFn): Promise { + 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)); +} diff --git a/tests/watchlist-resolve.test.ts b/tests/watchlist-resolve.test.ts new file mode 100644 index 0000000..8d0424d --- /dev/null +++ b/tests/watchlist-resolve.test.ts @@ -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'); + }); +});