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)',
|
||||
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[] {
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
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<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);
|
||||
},
|
||||
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<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));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue