feat(watchlist): resolve-missing-ids command
This commit is contained in:
parent
aafa3bb1e4
commit
22a409ce9b
4 changed files with 156 additions and 15 deletions
|
|
@ -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[] {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
36
packages/obsidian/src/watchlist/resolve.ts
Normal file
36
packages/obsidian/src/watchlist/resolve.ts
Normal 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));
|
||||||
|
}
|
||||||
37
tests/watchlist-resolve.test.ts
Normal file
37
tests/watchlist-resolve.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue