diff --git a/packages/obsidian/src/watchlist/SyncEngine.ts b/packages/obsidian/src/watchlist/SyncEngine.ts index 03a599c..41f382d 100644 --- a/packages/obsidian/src/watchlist/SyncEngine.ts +++ b/packages/obsidian/src/watchlist/SyncEngine.ts @@ -30,6 +30,18 @@ export class TmdbRateLimitError extends Error { retryAfterMs: number = 2000; } +export async function withRateLimitRetry(fn: () => Promise, sleep: (ms: number) => Promise): Promise { + try { + return await fn(); + } catch (e) { + if (e instanceof TmdbRateLimitError) { + await sleep(e.retryAfterMs); + return await fn(); + } + throw e; + } +} + const ACTIVE_STATUSES = new Set(['Returning Series', 'In Production', 'Planned', 'Pilot']); function strip(s: string | undefined): string { @@ -63,17 +75,7 @@ export async function syncFolder(deps: SyncDeps, opts: SyncOptions = {}): Promis continue; } try { - let detail: any; - try { - detail = await deps.fetchDetail(ref.tmdbId, ref.isMovie); - } catch (e) { - if (e instanceof TmdbRateLimitError) { - await deps.sleep(e.retryAfterMs); - detail = await deps.fetchDetail(ref.tmdbId, ref.isMovie); - } else { - throw e; - } - } + const detail: any = await withRateLimitRetry(() => deps.fetchDetail(ref.tmdbId, ref.isMovie), deps.sleep); const record = buildRecord(detail, ref.isMovie, frontmatter); const rendered = renderNote(record, extractMyNotes(body)); report.synced++; diff --git a/packages/obsidian/src/watchlist/WatchlistController.ts b/packages/obsidian/src/watchlist/WatchlistController.ts index d2cc200..44bfa5f 100644 --- a/packages/obsidian/src/watchlist/WatchlistController.ts +++ b/packages/obsidian/src/watchlist/WatchlistController.ts @@ -1,6 +1,6 @@ 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 { syncFolder, withRateLimitRetry, TmdbRateLimitError, type SyncDeps, type SyncReport } from 'packages/obsidian/src/watchlist/SyncEngine'; 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'; @@ -111,7 +111,7 @@ export class WatchlistController { report.scanned++; try { const filename = note.path.split('/').pop() ?? note.path; - const result = await resolveNote(frontmatter, filename, search); + const result = await withRateLimitRetry(() => resolveNote(frontmatter, filename, search), deps.sleep); if (!result) { report.ambiguous++; deps.log(`ambiguous/no match: ${note.path}`); diff --git a/packages/obsidian/src/watchlist/resolve.ts b/packages/obsidian/src/watchlist/resolve.ts index 948fab9..a662d36 100644 --- a/packages/obsidian/src/watchlist/resolve.ts +++ b/packages/obsidian/src/watchlist/resolve.ts @@ -18,8 +18,8 @@ async function tryOne(query: string, isMovie: boolean, year: string | undefined, 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); + const exacts = results.filter(r => resultTitles(r).includes(q)); + const pick = exacts.length === 1 ? exacts[0] : exacts.length === 0 && results.length === 1 ? results[0] : null; if (!pick) return null; return { tmdbId: String(pick.id), isMovie, matchedTitle: resultTitle(pick) }; } diff --git a/tests/watchlist-resolve.test.ts b/tests/watchlist-resolve.test.ts index 8d0424d..245f21f 100644 --- a/tests/watchlist-resolve.test.ts +++ b/tests/watchlist-resolve.test.ts @@ -34,4 +34,16 @@ describe('resolveNote', () => { }); expect(seenYear).toBe('2024'); }); + test('two exact matches (same title, different ids) → null (ambiguous)', async () => { + const RH1 = { id: 1, title: 'Robin Hood', original_title: 'Robin Hood' }; + const RH2 = { id: 2, title: 'Robin Hood', original_title: 'Robin Hood' }; + const r = await resolveNote({ media_type: 'Movie' }, 'Robin Hood.md', async () => [RH1, RH2]); + expect(r).toBeNull(); + }); + test('exact match at non-top index, unique → accepted', async () => { + const NOPE = { id: 9, title: 'Robin Hood Begins', original_title: 'Robin Hood Begins' }; + const HIT2 = { id: 10, title: 'Robin Hood', original_title: 'Robin Hood' }; + const r = await resolveNote({ media_type: 'Movie' }, 'Robin Hood.md', async () => [NOPE, HIT2]); + expect(r).toEqual({ tmdbId: '10', isMovie: true, matchedTitle: 'Robin Hood' }); + }); }); diff --git a/tests/watchlist-sync-engine.test.ts b/tests/watchlist-sync-engine.test.ts index 66e817a..6318ff8 100644 --- a/tests/watchlist-sync-engine.test.ts +++ b/tests/watchlist-sync-engine.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { syncFolder, isActive, TmdbRateLimitError, type SyncDeps } from 'packages/obsidian/src/watchlist/SyncEngine'; +import { syncFolder, isActive, TmdbRateLimitError, withRateLimitRetry, type SyncDeps } from 'packages/obsidian/src/watchlist/SyncEngine'; import tvDetail from 'tests/fixtures/tmdb-tv-loki.json'; const ENDED_NOTE = `--- @@ -35,6 +35,46 @@ function makeDeps(notes: { path: string; content: string }[], detail: any = tvDe return { deps, writes, fetches }; } +describe('withRateLimitRetry', () => { + test('429 → sleep(retryAfterMs) then retry once, returns result', async () => { + const slept: number[] = []; + let calls = 0; + const fn = async () => { + calls++; + if (calls === 1) { + const e = new TmdbRateLimitError('429'); + e.retryAfterMs = 1500; + throw e; + } + return 'ok'; + }; + const result = await withRateLimitRetry(fn, async ms => { slept.push(ms); }); + expect(slept).toEqual([1500]); + expect(calls).toBe(2); + expect(result).toBe('ok'); + }); + test('non-429 error → no retry, throws immediately', async () => { + let calls = 0; + const fn = async () => { + calls++; + throw new Error('boom'); + }; + await expect(withRateLimitRetry(fn, async () => {})).rejects.toThrow('boom'); + expect(calls).toBe(1); + }); + test('429 twice → second failure propagates (retry only once)', async () => { + let calls = 0; + const fn = async () => { + calls++; + const e = new TmdbRateLimitError('429'); + e.retryAfterMs = 500; + throw e; + }; + await expect(withRateLimitRetry(fn, async () => {})).rejects.toBeInstanceOf(TmdbRateLimitError); + expect(calls).toBe(2); + }); +}); + describe('isActive tiering', () => { test('Returning Series → active', () => expect(isActive({ status: 'Returning Series' })).toBe(true)); test('Watching → active regardless of status', () => expect(isActive({ status: 'Ended', watch_status: 'Watching' })).toBe(true));