fix(watchlist): resolve 429 backoff + unique-exact-match acceptance
This commit is contained in:
parent
22a409ce9b
commit
b77a189848
5 changed files with 70 additions and 16 deletions
|
|
@ -30,6 +30,18 @@ export class TmdbRateLimitError extends Error {
|
|||
retryAfterMs: number = 2000;
|
||||
}
|
||||
|
||||
export async function withRateLimitRetry<T>(fn: () => Promise<T>, sleep: (ms: number) => Promise<void>): Promise<T> {
|
||||
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++;
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue