obsidian-media-db-sync/docs/superpowers/plans/2026-07-29-watchlist-suite.md

1580 lines
64 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Watchlist Suite Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add full metadata enrichment, periodic diff-on-write sync, and watch-status automation (Watched→Unwatched on new episode) to the media-db-sync fork, emitting the canonical watchlist schema.
**Architecture:** New self-contained module `packages/obsidian/src/watchlist/` — pure functions (TMDB JSON → record → note string) with HTTP and vault I/O injected, plus a `SyncEngine` orchestrator. Thin wiring into `main.ts` (commands + `registerInterval` catch-up scheduler) and `Settings.ts`. Port of the proven Python reference (`watchlist_sync.py`) with three additions: `seasons`/`episodes`/`vod` fields, tiered sync, resolve-missing-ids.
**Tech Stack:** TypeScript, bun test (preload `tests/setup.ts` mocks `obsidian`), vite build, Obsidian `requestUrl` via existing `obsidianFetch` (`packages/obsidian/src/utils/Utils.ts:283`).
## Global Constraints
- ALL HTTP via Obsidian `requestUrl` (`obsidianFetch`) — never node `fetch` (ISP DNS blocks TMDB for node stack).
- TMDB auth BOTH: key starts `eyJ` → v4 `Authorization: Bearer` header; else v3 `&api_key=` query param.
- Preserve on every write: `watch_status` (except TV rule), `rating`, `rating_stars`, `notion_url`, body `## My Notes`.
- Schema = exact canonical frontmatter (see `tests/fixtures/canonical-series.md`). Always emit `seasons`/`episodes`/`vod` (movies: `null`/`null`/`[]`) — verified against real vault notes.
- Dates stay ISO strings end-to-end; compare lexicographically. No `Date` parsing.
- Diff-on-write: render full note, string-compare vs existing content, write only when different.
- Test commands: `bun run test` (NEVER raw `bun test` — misses preload), `bun run tsc`, `bun run build`. All three must pass before every commit.
- Reference implementation: `/Users/AfiqZudinHadi/Documents/obsidian-media-db/reference/watchlist_sync.py`. Field mappings spec: `/Users/AfiqZudinHadi/Documents/obsidian-media-db/docs/SPEC.md`.
- Commit format: end body with `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`.
---
### Task 1: YAML helpers + schema types
**Files:**
- Create: `packages/obsidian/src/watchlist/schema.ts`
- Create: `packages/obsidian/src/watchlist/yaml.ts`
- Test: `tests/watchlist-yaml.test.ts`
**Interfaces:**
- Produces: `WatchlistRecord` interface (all downstream tasks build/consume it); `yamlScalar(v: unknown): string`, `yamlList(items: string[]): string`, `quotedOrNull(v: string | null | undefined): string`.
- [ ] **Step 1: Write the failing test**
```ts
// tests/watchlist-yaml.test.ts
import { describe, expect, test } from 'bun:test';
import { yamlScalar, yamlList, quotedOrNull } from 'packages/obsidian/src/watchlist/yaml';
describe('yamlScalar', () => {
test('plain string passes through', () => {
expect(yamlScalar('English')).toBe('English');
});
test('empty/null/undefined → empty string', () => {
expect(yamlScalar('')).toBe('');
expect(yamlScalar(null)).toBe('');
expect(yamlScalar(undefined)).toBe('');
});
test('special chars → quoted', () => {
expect(yamlScalar('S2, E6: Glorious Purpose')).toBe('"S2, E6: Glorious Purpose"');
expect(yamlScalar('Aaron Moorhead, Justin Benson')).toBe('"Aaron Moorhead, Justin Benson"');
});
test('comma alone does NOT quote (matches python regex)', () => {
// python regex [:#\[\]{}",&*!|>%@`] has no comma; "a, b" quotes via ":"? no — verify: comma not in class, so "Alter, Turtle" stays plain
expect(yamlScalar('Rachel Alter and Tommy Turtle')).toBe('Rachel Alter and Tommy Turtle');
});
test('yaml keywords → quoted', () => {
expect(yamlScalar('null')).toBe('"null"');
expect(yamlScalar('No')).toBe('"No"');
});
test('embedded quotes escaped', () => {
expect(yamlScalar('He said "hi"')).toBe('"He said \\"hi\\""');
});
});
describe('yamlList', () => {
test('plain items unquoted, special quoted', () => {
expect(yamlList(['Drama', 'Sci-Fi & Fantasy'])).toBe('[Drama, "Sci-Fi & Fantasy"]');
});
test('empty list', () => {
expect(yamlList([])).toBe('[]');
});
test('blank items dropped', () => {
expect(yamlList(['', 'Drama', ' '])).toBe('[Drama]');
});
test('Disney+ quoted', () => {
expect(yamlList(['Disney+'])).toBe('["Disney+"]');
});
});
describe('quotedOrNull', () => {
test('value → quoted', () => {
expect(quotedOrNull('https://x.y/z')).toBe('"https://x.y/z"');
});
test('empty/null → null literal', () => {
expect(quotedOrNull('')).toBe('null');
expect(quotedOrNull(null)).toBe('null');
});
});
```
NOTE for implementer: the "comma alone" test documents python behavior — `,` is NOT in the quoting regex. `'S2, E6: Glorious Purpose'` quotes because of `:`. `'Aaron Moorhead, Justin Benson'` — check python regex: no comma, no colon in that string… BUT the canonical fixture shows `director: "Aaron Moorhead, Justin Benson"` QUOTED. Reality: the vault was written by an earlier variant that quoted on comma. **Decision: add `,` to the quoting regex** so output matches the real vault. Test expectations above are written for the comma-quoting variant (`'Rachel Alter and Tommy Turtle'` has no comma → plain).
- [ ] **Step 2: Run test to verify it fails**
Run: `bun run test 2>&1 | tail -5`
Expected: FAIL — `Cannot find module 'packages/obsidian/src/watchlist/yaml'`
- [ ] **Step 3: Write minimal implementation**
```ts
// packages/obsidian/src/watchlist/yaml.ts
const NEEDS_QUOTE = /[:,#\[\]{}",&*!|>%@`]/;
const KEYWORDS = new Set(['null', 'true', 'false', 'yes', 'no']);
export function yamlScalar(v: unknown): string {
if (v === null || v === undefined || v === '') return '';
const s = String(v);
if (NEEDS_QUOTE.test(s) || s.trim() !== s || KEYWORDS.has(s.toLowerCase())) {
return '"' + s.replace(/"/g, '\\"') + '"';
}
return s;
}
export function yamlList(items: string[]): string {
const parts: string[] = [];
for (const raw of items) {
const g = String(raw).trim();
if (!g) continue;
parts.push(/^[A-Za-z0-9 ]+$/.test(g) ? g : '"' + g.replace(/"/g, '\\"') + '"');
}
return '[' + parts.join(', ') + ']';
}
export function quotedOrNull(v: string | null | undefined): string {
return v ? '"' + v + '"' : 'null';
}
```
```ts
// packages/obsidian/src/watchlist/schema.ts
export type WatchCategory = 'Movie' | 'Series' | 'Anime';
export type WatchMediaType = 'Movie' | 'TV Series';
export interface WatchlistRecord {
title: string;
engName: string;
mediaType: WatchMediaType;
category: WatchCategory;
watchStatus: string;
rating: string;
ratingStars: string;
year: string;
runtime: number | null;
seasons: number | null;
episodes: number | null;
vod: string[];
genre: string[];
status: string;
language: string;
country: string;
director: string[];
writer: string[];
producer: string[];
contentRating: string;
tmdbRating: number | null;
tmdbId: string;
imdbId: string;
releaseDate: string | null;
lastAirDate: string | null;
nextAirDate: string | null;
lastEpisode: string | null;
upcomingEpisode: string | null;
poster: string | null;
trailer: string;
homepage: string;
imdbPage: string;
notionUrl: string;
synopsis: string;
cast: string;
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `bun run test 2>&1 | tail -5`
Expected: all pass (31 upstream + new)
- [ ] **Step 5: Commit**
```bash
git add packages/obsidian/src/watchlist/ tests/watchlist-yaml.test.ts
git commit -m "feat(watchlist): yaml helpers + schema types"
```
---
### Task 2: Note parsing (frontmatter, My Notes, TMDB ref)
**Files:**
- Create: `packages/obsidian/src/watchlist/parse.ts`
- Test: `tests/watchlist-parse.test.ts`
**Interfaces:**
- Produces: `parseNote(content: string): { frontmatter: Record<string, string>; body: string }`; `extractMyNotes(body: string): string`; `noteTmdbRef(fm: Record<string, string>): { tmdbId: string; isMovie: boolean } | null`.
- [ ] **Step 1: Write the failing test**
```ts
// tests/watchlist-parse.test.ts
import { describe, expect, test } from 'bun:test';
import { parseNote, extractMyNotes, noteTmdbRef } from 'packages/obsidian/src/watchlist/parse';
const NOTE = `---
type: watchlist_item
media_type: TV Series
tmdb_id: 84958
last_air_date: 2023-11-09
watch_status: Watched
---
# Loki
## My Notes
great finale
`;
describe('parseNote', () => {
test('splits frontmatter and body', () => {
const { frontmatter, body } = parseNote(NOTE);
expect(frontmatter['tmdb_id']).toBe('84958');
expect(frontmatter['media_type']).toBe('TV Series');
expect(body).toContain('# Loki');
});
test('no frontmatter → empty fm, full body', () => {
const { frontmatter, body } = parseNote('# Just a heading');
expect(Object.keys(frontmatter).length).toBe(0);
expect(body).toBe('# Just a heading');
});
});
describe('extractMyNotes', () => {
test('extracts trailing section', () => {
expect(extractMyNotes(parseNote(NOTE).body)).toBe('great finale');
});
test('missing section → empty', () => {
expect(extractMyNotes('# T\n\ncontent')).toBe('');
});
});
describe('noteTmdbRef', () => {
test('canonical note', () => {
expect(noteTmdbRef({ tmdb_id: '84958', media_type: 'TV Series' })).toEqual({ tmdbId: '84958', isMovie: false });
expect(noteTmdbRef({ tmdb_id: '693134', media_type: 'Movie' })).toEqual({ tmdbId: '693134', isMovie: true });
});
test('quoted values stripped', () => {
expect(noteTmdbRef({ tmdb_id: '"84958"', media_type: '"TV Series"' })).toEqual({ tmdbId: '84958', isMovie: false });
});
test('raw Media DB note fallback (id + dataSource)', () => {
expect(noteTmdbRef({ id: '693134', dataSource: 'TMDBMovieAPI' })).toEqual({ tmdbId: '693134', isMovie: true });
expect(noteTmdbRef({ id: '84958', dataSource: 'TMDBSeriesAPI' })).toEqual({ tmdbId: '84958', isMovie: false });
});
test('no id → null', () => {
expect(noteTmdbRef({ type: 'list' })).toBeNull();
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `bun run test 2>&1 | tail -5`
Expected: FAIL — `Cannot find module 'packages/obsidian/src/watchlist/parse'`
- [ ] **Step 3: Write minimal implementation**
```ts
// packages/obsidian/src/watchlist/parse.ts
export interface ParsedNote {
frontmatter: Record<string, string>;
body: string;
}
export function parseNote(content: string): ParsedNote {
const m = /^---\n([\s\S]*?)\n---([\s\S]*)$/.exec(content);
const frontmatter: Record<string, string> = {};
let body = content;
if (m) {
body = m[2];
for (const line of m[1].split('\n')) {
const mm = /^([A-Za-z0-9_]+):\s*(.*)$/.exec(line);
if (mm) frontmatter[mm[1]] = mm[2].trim();
}
}
return { frontmatter, body };
}
export function extractMyNotes(body: string): string {
const m = /##\s*My Notes\s*([\s\S]*)$/.exec(body ?? '');
return m ? m[1].trim() : '';
}
function stripQuotes(s: string | undefined): string {
return (s ?? '').trim().replace(/^"|"$/g, '');
}
export function noteTmdbRef(fm: Record<string, string>): { tmdbId: string; isMovie: boolean } | null {
const tid = stripQuotes(fm['tmdb_id']);
if (tid) return { tmdbId: tid, isMovie: stripQuotes(fm['media_type']) === 'Movie' };
const rawId = stripQuotes(fm['id']);
const ds = (fm['dataSource'] ?? '').trim();
if (rawId && ds.startsWith('TMDB')) return { tmdbId: rawId, isMovie: ds.includes('Movie') };
return null;
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `bun run test 2>&1 | tail -5`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add packages/obsidian/src/watchlist/parse.ts tests/watchlist-parse.test.ts
git commit -m "feat(watchlist): note parsing (frontmatter, My Notes, tmdb ref)"
```
---
### Task 3: buildRecord — TMDB detail → WatchlistRecord
**Files:**
- Create: `packages/obsidian/src/watchlist/build.ts`
- Create: `tests/fixtures/tmdb-movie-dune2.json` (mock payload — copy verbatim from `watchlist_sync.py` `selftest()` L301-316, JSON-ified: `True``true`)
- Create: `tests/fixtures/tmdb-tv-loki.json` (mock below)
- Test: `tests/watchlist-build.test.ts`
**Interfaces:**
- Consumes: `WatchlistRecord` (Task 1).
- Produces: `buildRecord(details: TmdbDetail, isMovie: boolean, prev: Record<string, string>): WatchlistRecord` where `TmdbDetail = Record<string, any>` (raw TMDB JSON).
`tests/fixtures/tmdb-tv-loki.json`:
```json
{
"id": 84958, "name": "Loki", "original_name": "Loki",
"original_language": "en",
"spoken_languages": [{ "iso_639_1": "en", "english_name": "English" }],
"overview": "After stealing the Tesseract...",
"episode_run_time": [], "status": "Ended",
"first_air_date": "2021-06-09", "last_air_date": "2023-11-09",
"number_of_seasons": 2, "number_of_episodes": 12,
"networks": [{ "name": "Disney+" }],
"vote_average": 8.2, "homepage": "https://www.disneyplus.com/series/wp/6pARMvILBGzF",
"poster_path": null,
"genres": [{ "name": "Drama" }, { "name": "Sci-Fi & Fantasy" }],
"origin_country": ["United States of America"],
"created_by": [{ "name": "Michael Waldron" }],
"aggregate_credits": { "cast": [{ "name": "Tom Hiddleston" }, { "name": "Sophia Di Martino" }] },
"external_ids": { "imdb_id": "tt9140554" },
"content_ratings": { "results": [{ "iso_3166_1": "US", "rating": "TV-14" }] },
"videos": { "results": [{ "site": "YouTube", "type": "Trailer", "official": true, "key": "nW948Va-l10" }] },
"last_episode_to_air": { "season_number": 2, "episode_number": 6, "name": "Glorious Purpose" },
"next_episode_to_air": null
}
```
- [ ] **Step 1: Write the failing test**
```ts
// tests/watchlist-build.test.ts
import { describe, expect, test } from 'bun:test';
import { buildRecord } from 'packages/obsidian/src/watchlist/build';
import movieDetail from 'tests/fixtures/tmdb-movie-dune2.json';
import tvDetail from 'tests/fixtures/tmdb-tv-loki.json';
const EMPTY_PREV = { watch_status: 'Unwatched', rating: '0', rating_stars: '' };
describe('buildRecord movie', () => {
const r = buildRecord(movieDetail, true, EMPTY_PREV);
test('core mapping (mirrors python selftest)', () => {
expect(r.language).toBe('English');
expect(r.country).toBe('United States of America');
expect(r.imdbId).toBe('tt15239678');
expect(r.contentRating).toBe('PG-13');
expect(r.tmdbId).toBe('693134');
expect(r.category).toBe('Movie');
expect(r.trailer).toContain('youtube.com');
expect(r.year).toBe('2024');
expect(r.director).toEqual(['Denis Villeneuve']);
expect(r.writer).toEqual(['Jon Spaihts']);
expect(r.producer).toEqual(['Mary Parent']);
expect(r.imdbPage).toBe('https://www.imdb.com/title/tt15239678/');
});
test('movie: seasons/episodes null, vod empty', () => {
expect(r.seasons).toBeNull();
expect(r.episodes).toBeNull();
expect(r.vod).toEqual([]);
});
});
describe('buildRecord tv', () => {
const r = buildRecord(tvDetail, false, EMPTY_PREV);
test('tv mapping', () => {
expect(r.mediaType).toBe('TV Series');
expect(r.category).toBe('Series');
expect(r.year).toBe('2021 - 2023');
expect(r.seasons).toBe(2);
expect(r.episodes).toBe(12);
expect(r.vod).toEqual(['Disney+']);
expect(r.contentRating).toBe('TV-14');
expect(r.lastEpisode).toBe('S2, E6: Glorious Purpose');
expect(r.upcomingEpisode).toBeNull();
expect(r.runtime).toBeNull();
expect(r.director).toEqual(['Michael Waldron']);
});
test('ongoing series year + TBA', () => {
const ongoing = { ...tvDetail, status: 'Returning Series', last_air_date: '2026-01-01', next_episode_to_air: null };
const r2 = buildRecord(ongoing, false, EMPTY_PREV);
expect(r2.year).toBe('2021 -');
expect(r2.upcomingEpisode).toBe('TBA');
});
test('same start/end year collapses', () => {
const oneYear = { ...tvDetail, first_air_date: '2021-06-09', last_air_date: '2021-07-14' };
expect(buildRecord(oneYear, false, EMPTY_PREV).year).toBe('2021');
});
});
describe('anime derivation', () => {
test('Animation + Japanese → Anime', () => {
const anime = {
...tvDetail,
genres: [{ name: 'Animation' }, { name: 'Drama' }],
original_language: 'ja',
spoken_languages: [{ iso_639_1: 'ja', english_name: 'Japanese' }],
};
expect(buildRecord(anime, false, EMPTY_PREV).category).toBe('Anime');
});
test('Animation + English → not Anime', () => {
const western = { ...tvDetail, genres: [{ name: 'Animation' }] };
expect(buildRecord(western, false, EMPTY_PREV).category).toBe('Series');
});
});
describe('user-field preservation', () => {
test('prev fields carried', () => {
const prev = { watch_status: 'Watching', rating: '4', rating_stars: '⭐️⭐️⭐️⭐️', notion_url: 'https://notion.so/x' };
const r = buildRecord(tvDetail, false, prev);
expect(r.watchStatus).toBe('Watching');
expect(r.rating).toBe('4');
expect(r.ratingStars).toBe('⭐️⭐️⭐️⭐️');
expect(r.notionUrl).toBe('https://notion.so/x');
});
});
describe('watch-status rule (TV)', () => {
test('Watched + newer episode → Unwatched', () => {
const prev = { watch_status: 'Watched', last_air_date: '2023-10-01', rating: '5', rating_stars: '⭐️⭐️⭐️⭐️⭐️' };
expect(buildRecord(tvDetail, false, prev).watchStatus).toBe('Unwatched');
});
test('Watched + same date → stays Watched', () => {
const prev = { watch_status: 'Watched', last_air_date: '2023-11-09' };
expect(buildRecord(tvDetail, false, prev).watchStatus).toBe('Watched');
});
test('movie never flips', () => {
const prev = { watch_status: 'Watched', last_air_date: '2020-01-01' };
expect(buildRecord(movieDetail, true, prev).watchStatus).toBe('Watched');
});
test('no prev last_air_date → no flip', () => {
const prev = { watch_status: 'Watched' };
expect(buildRecord(tvDetail, false, prev).watchStatus).toBe('Watched');
});
});
describe('eng_name derivation', () => {
test('non-Latin original → engName = localized title', () => {
const jp = { ...movieDetail, title: 'A Silent Voice: The Movie', original_title: '映画 聲の形' };
expect(buildRecord(jp, true, EMPTY_PREV).engName).toBe('A Silent Voice: The Movie');
});
test('same Latin title → empty', () => {
expect(buildRecord(movieDetail, true, EMPTY_PREV).engName).toBe('');
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `bun run test 2>&1 | tail -5`
Expected: FAIL — module not found
- [ ] **Step 3: Write implementation (direct port of `build_record`, watchlist_sync.py L120-214, + seasons/episodes/vod)**
```ts
// packages/obsidian/src/watchlist/build.ts
import type { WatchlistRecord } from 'packages/obsidian/src/watchlist/schema';
export type TmdbDetail = Record<string, any>;
const IMG_BASE = 'https://image.tmdb.org/t/p/original';
const LANG_FALLBACK: Record<string, string> = {
en: 'English', ja: 'Japanese', ko: 'Korean', zh: 'Chinese', fr: 'French',
es: 'Spanish', de: 'German', hi: 'Hindi', ta: 'Tamil', th: 'Thai',
};
function langName(details: TmdbDetail): string {
const code: string = details.original_language ?? '';
for (const sl of details.spoken_languages ?? []) {
if (sl.iso_639_1 === code && sl.english_name) return sl.english_name;
}
return LANG_FALLBACK[code] ?? code;
}
function crewNames(crew: any[], jobs: Set<string>): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const c of crew) {
if (jobs.has(c.job) && !seen.has(c.name)) {
seen.add(c.name);
out.push(c.name);
}
}
return out;
}
function usCertFromReleaseDates(rd: TmdbDetail): string {
for (const entry of rd?.results ?? []) {
if (entry.iso_3166_1 === 'US') {
for (const d of entry.release_dates ?? []) {
if (d.certification) return d.certification;
}
}
}
return '';
}
function usCertFromContentRatings(cr: TmdbDetail): string {
for (const entry of cr?.results ?? []) {
if (entry.iso_3166_1 === 'US' && entry.rating) return entry.rating;
}
return '';
}
function pickTrailer(videos: TmdbDetail): string {
const vids: any[] = videos?.results ?? [];
const yt = (v: any): string => 'https://www.youtube.com/watch?v=' + v.key;
for (const v of vids) if (v.site === 'YouTube' && v.type === 'Trailer' && v.official) return yt(v);
for (const v of vids) if (v.site === 'YouTube' && v.type === 'Trailer') return yt(v);
return '';
}
export function buildRecord(details: TmdbDetail, isMovie: boolean, prev: Record<string, string>): WatchlistRecord {
const genres: string[] = (details.genres ?? []).map((g: any) => g.name);
const language = langName(details);
const imdbId: string = details.external_ids?.imdb_id ?? '';
const trailer = pickTrailer(details.videos ?? {});
let title: string, originalTitle: string, mediaType: 'Movie' | 'TV Series';
let releaseDate: string | null, runtime: number | null, status: string, contentRating: string, country: string;
let cast: string[], director: string[], writer: string[], producer: string[];
let seasons: number | null, episodes: number | null, vod: string[];
let lastAirDate: string | null = null, nextAirDate: string | null = null;
let lastEpisode: string | null = null, upEpisode: string | null = null;
let yearDisp: string;
if (isMovie) {
const credits = details.credits ?? {};
cast = (credits.cast ?? []).slice(0, 12).map((c: any) => c.name);
const crew: any[] = credits.crew ?? [];
director = crewNames(crew, new Set(['Director']));
writer = crewNames(crew, new Set(['Writer', 'Screenplay', 'Story']));
producer = crewNames(crew, new Set(['Producer']));
title = details.title ?? '';
originalTitle = details.original_title ?? '';
mediaType = 'Movie';
releaseDate = details.release_date || null;
runtime = details.runtime || null;
status = details.status ?? '';
contentRating = usCertFromReleaseDates(details.release_dates ?? {});
const countries: string[] = (details.production_countries ?? []).map((c: any) => c.name);
country = countries[0] ?? '';
yearDisp = (releaseDate ?? '').slice(0, 4);
seasons = null;
episodes = null;
vod = [];
} else {
const agg = details.aggregate_credits ?? {};
cast = (agg.cast ?? []).slice(0, 12).map((c: any) => c.name);
const createdBy: string[] = (details.created_by ?? []).map((c: any) => c.name);
director = createdBy; // series: creators (latest-episode director needs extra call — Phase 3)
writer = createdBy;
producer = [];
title = details.name ?? '';
originalTitle = details.original_name ?? '';
mediaType = 'TV Series';
releaseDate = details.first_air_date || null;
const rt: number[] = details.episode_run_time ?? [];
runtime = rt.length > 0 ? rt[0] : null;
status = details.status ?? '';
contentRating = usCertFromContentRatings(details.content_ratings ?? {});
const countries: string[] = details.origin_country ?? [];
country = countries[0] ?? '';
seasons = details.number_of_seasons ?? null;
episodes = details.number_of_episodes ?? null;
vod = (details.networks ?? []).map((n: any) => n.name);
lastAirDate = details.last_air_date || null;
const le = details.last_episode_to_air;
const ne = details.next_episode_to_air;
lastEpisode = le ? `S${le.season_number}, E${le.episode_number}: ${le.name}` : null;
upEpisode = ne
? `S${ne.season_number}, E${ne.episode_number}: ${ne.name}`
: ['Returning Series', 'Pilot'].includes(status)
? 'TBA'
: null;
nextAirDate = ne?.air_date ?? null;
const start = (releaseDate ?? '').slice(0, 4);
const ended = ['Ended', 'Canceled', 'Cancelled'].includes(status);
const end = ended ? (lastAirDate ?? '').slice(0, 4) : null;
yearDisp = end && end !== start ? `${start} - ${end}` : start && !ended ? `${start} -` : start;
}
const category: 'Movie' | 'Series' | 'Anime' =
genres.includes('Animation') && language === 'Japanese' ? 'Anime' : isMovie ? 'Movie' : 'Series';
const poster = details.poster_path ? IMG_BASE + details.poster_path : null;
let engName = '';
// eslint-disable-next-line no-control-regex
if (originalTitle && originalTitle !== title && !/^[\x00-\x7F ]+$/.test(originalTitle)) {
engName = title; // original is non-Latin → english name is the localized title
}
// ---- preserve user-managed fields ----
let watchStatus = prev['watch_status'] || 'Unwatched';
const rating = prev['rating'] || '0';
const ratingStars = prev['rating_stars'] ?? '';
const notionUrl = prev['notion_url'] || '';
// ---- TV watch-status rule: new episode aired since last sync ----
const prevLast = (prev['last_air_date'] ?? '').trim().replace(/^"|"$/g, '');
if (!isMovie && watchStatus === 'Watched' && lastAirDate && prevLast && lastAirDate > prevLast) {
watchStatus = 'Unwatched';
}
return {
title, engName, mediaType, category,
watchStatus, rating, ratingStars,
year: yearDisp, runtime, seasons, episodes, vod, genre: genres, status,
language, country, director, writer, producer, contentRating,
tmdbRating: details.vote_average ?? null, tmdbId: String(details.id),
imdbId, releaseDate, lastAirDate, nextAirDate, lastEpisode, upcomingEpisode: upEpisode,
poster, trailer, homepage: details.homepage ?? '',
imdbPage: imdbId ? `https://www.imdb.com/title/${imdbId}/` : '',
notionUrl, synopsis: details.overview ?? '',
cast: cast.join(', '),
};
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `bun run test 2>&1 | tail -5`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add packages/obsidian/src/watchlist/build.ts tests/watchlist-build.test.ts tests/fixtures/
git commit -m "feat(watchlist): buildRecord — TMDB detail → canonical record"
```
---
### Task 4: renderNote — record → note string (golden test)
**Files:**
- Create: `packages/obsidian/src/watchlist/render.ts`
- Create: `tests/fixtures/canonical-series.md` — copy verbatim from `/Users/AfiqZudinHadi/Documents/obsidian-media-db/reference/sample_canonical_series.md`
- Test: `tests/watchlist-render.test.ts`
**Interfaces:**
- Consumes: `WatchlistRecord`, yaml helpers.
- Produces: `renderNote(r: WatchlistRecord, myNotes: string): string` — full note content, frontmatter order exactly: `type, category, media_type, watch_status, rating, rating_stars, year, runtime, seasons, episodes, vod, genre, status, language, country, director, writer, producer, content_rating, tmdb_rating, tmdb_id, imdb_id, release_date, last_air_date, next_air_date, last_episode, upcoming_episode, eng_name, poster, trailer, homepage, imdb_page, notion_url, tags`.
- [ ] **Step 1: Write the failing test**
```ts
// tests/watchlist-render.test.ts
import { describe, expect, test } from 'bun:test';
import { renderNote } from 'packages/obsidian/src/watchlist/render';
import type { WatchlistRecord } from 'packages/obsidian/src/watchlist/schema';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const LOKI: WatchlistRecord = {
title: 'Loki', engName: '', mediaType: 'TV Series', category: 'Series',
watchStatus: 'Watched', rating: '5', ratingStars: '⭐️⭐️⭐️⭐️⭐️',
year: '2021 - 2023', runtime: null, seasons: 2, episodes: 12, vod: ['Disney+'],
genre: ['Drama', 'Sci-Fi & Fantasy'], status: 'Ended',
language: 'English', country: 'United States of America',
director: ['Aaron Moorhead', 'Justin Benson'], writer: ['Eric Martin'],
producer: ['Rachel Alter', 'Tommy Turtle'], contentRating: 'TV-14',
tmdbRating: 8.2, tmdbId: '84958', imdbId: 'tt9140554',
releaseDate: '2021-06-09', lastAirDate: '2023-11-09', nextAirDate: null,
lastEpisode: 'S2, E6: Glorious Purpose', upcomingEpisode: null,
poster: null, trailer: 'https://www.youtube.com/watch?v=nW948Va-l10',
homepage: 'https://www.disneyplus.com/series/wp/6pARMvILBGzF',
imdbPage: 'https://www.imdb.com/title/tt9140554/',
notionUrl: 'https://www.notion.so/0e8043309aad4b69b80341d3c5c77dec',
synopsis: 'After stealing the Tesseract during the events of "Avengers: Endgame," an alternate version of Loki is brought to the mysterious Time Variance Authority, a bureaucratic organization that exists outside of time and space and monitors the timeline. They give Loki a choice: face being erased from existence due to being a "time variant" or help fix the timeline and stop a greater threat.',
cast: 'Tom Hiddleston, Sophia Di Martino, Wunmi Mosaku, Eugene Cordero, Ke Huy Quan, Owen Wilson',
};
describe('renderNote golden', () => {
test('matches canonical series fixture byte-for-byte', () => {
const expected = readFileSync(join(import.meta.dir, 'fixtures', 'canonical-series.md'), 'utf-8');
expect(renderNote(LOKI, '')).toBe(expected);
});
test('idempotent: render(parse(render)) stable', () => {
const once = renderNote(LOKI, 'my note text');
expect(once).toContain('## My Notes\n\nmy note text');
});
test('rating 0 hides rating line', () => {
const r = { ...LOKI, rating: '0', ratingStars: '' };
expect(renderNote(r, '')).not.toContain('**Rating:**');
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `bun run test 2>&1 | tail -5`
Expected: FAIL — module not found
- [ ] **Step 3: Write implementation (port of `render_note`, watchlist_sync.py L216-267, + seasons/episodes/vod lines)**
```ts
// packages/obsidian/src/watchlist/render.ts
import type { WatchlistRecord } from 'packages/obsidian/src/watchlist/schema';
import { yamlList, yamlScalar, quotedOrNull } from 'packages/obsidian/src/watchlist/yaml';
const CATEGORY_TAG: Record<string, string> = { Movie: 'movie', Series: 'series', Anime: 'anime' };
export function renderNote(r: WatchlistRecord, myNotes: string): string {
const tag = CATEGORY_TAG[r.category];
const stars = r.ratingStars;
const fm = [
'---',
'type: watchlist_item',
`category: ${r.category}`,
`media_type: ${r.mediaType}`,
`watch_status: ${r.watchStatus}`,
`rating: ${r.rating}`,
`rating_stars: ${stars}`,
`year: ${r.year || ''}`,
`runtime: ${r.runtime ? r.runtime : 'null'}`,
`seasons: ${r.seasons ?? 'null'}`,
`episodes: ${r.episodes ?? 'null'}`,
`vod: ${yamlList(r.vod)}`,
`genre: ${yamlList(r.genre)}`,
`status: ${r.status}`,
`language: ${yamlScalar(r.language)}`,
`country: ${yamlScalar(r.country)}`,
`director: ${yamlScalar(r.director.join(', '))}`,
`writer: ${yamlScalar(r.writer.join(', '))}`,
`producer: ${yamlScalar(r.producer.join(', '))}`,
`content_rating: ${r.contentRating}`,
`tmdb_rating: ${r.tmdbRating}`,
`tmdb_id: ${r.tmdbId}`,
`imdb_id: ${r.imdbId}`,
`release_date: ${r.releaseDate ? r.releaseDate : 'null'}`,
`last_air_date: ${r.lastAirDate ? r.lastAirDate : 'null'}`,
`next_air_date: ${r.nextAirDate ? r.nextAirDate : 'null'}`,
`last_episode: ${r.lastEpisode ? yamlScalar(r.lastEpisode) : ''}`,
`upcoming_episode: ${r.upcomingEpisode ? yamlScalar(r.upcomingEpisode) : ''}`,
`eng_name: ${yamlScalar(r.engName)}`,
`poster: ${quotedOrNull(r.poster)}`,
`trailer: ${quotedOrNull(r.trailer)}`,
`homepage: ${quotedOrNull(r.homepage)}`,
`imdb_page: ${quotedOrNull(r.imdbPage)}`,
`notion_url: ${quotedOrNull(r.notionUrl)}`,
`tags: [watchlist, ${tag}]`,
'---',
];
const b: string[] = ['', `# ${r.title}`];
if (r.engName) b.push(`*${r.engName}*`);
b.push('');
if (r.poster) b.push(`![poster|200](${r.poster})`, '');
const meta = [`**${r.category}**`, ...[r.year, r.language, r.country].filter(x => x)];
b.push(meta.join(' · '), '');
if (r.rating !== '0' && r.rating !== '' && stars) b.push(`**Rating:** ${stars} (${r.rating}/5)`);
b.push(`**Watch Status:** ${r.watchStatus}`);
if (r.tmdbRating) b.push(`**TMDB Rating:** ${r.tmdbRating}/10`);
b.push('');
if (r.synopsis) b.push('## Synopsis', r.synopsis, '');
const crew: string[] = [];
if (r.director.length) crew.push(`**Director:** ${r.director.join(', ')}`);
if (r.writer.length) crew.push(`**Writer:** ${r.writer.join(', ')}`);
if (r.producer.length) crew.push(`**Producer:** ${r.producer.join(', ')}`);
if (crew.length) b.push(...crew, '');
if (r.cast) b.push('## Cast', r.cast, '');
const links: string[] = [];
if (r.imdbPage) links.push(`- [IMDb](${r.imdbPage})`);
if (r.trailer) links.push(`- [Trailer](${r.trailer})`);
if (r.homepage) links.push(`- [Homepage](${r.homepage})`);
if (r.notionUrl) links.push(`- [Original Notion entry](${r.notionUrl})`);
if (links.length) b.push('## Links', ...links, '');
b.push('## My Notes', '', myNotes, '');
return fm.join('\n') + '\n' + b.join('\n');
}
```
NOTE for implementer: if the golden test fails on whitespace, diff the two strings char-by-char (`Bun.write` both to tmp files, `diff`) and adjust ONLY the fixture-vs-python discrepancies (e.g. trailing spaces after `last_episode:` when empty, final newline). The canonical fixture is the authority — real vault notes look like it.
- [ ] **Step 4: Run test to verify it passes**
Run: `bun run test 2>&1 | tail -5`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add packages/obsidian/src/watchlist/render.ts tests/watchlist-render.test.ts tests/fixtures/canonical-series.md
git commit -m "feat(watchlist): renderNote — canonical note serializer (golden-tested)"
```
---
### Task 5: TMDB detail client (auth v3/v4, append_to_response)
**Files:**
- Create: `packages/obsidian/src/watchlist/tmdb.ts`
- Test: `tests/watchlist-tmdb.test.ts`
**Interfaces:**
- Consumes: nothing internal (http injected).
- Produces: `type HttpJsonFn = (url: string, headers: Record<string, string>) => Promise<any>`; `fetchDetail(http: HttpJsonFn, key: string, tmdbId: string, isMovie: boolean): Promise<any>`; `searchTitle(http: HttpJsonFn, key: string, query: string, isMovie: boolean, year?: string): Promise<any[]>`.
- Plugin passes an adapter over `obsidianFetch` (Task 6/7); tests pass a stub.
- [ ] **Step 1: Write the failing test**
```ts
// tests/watchlist-tmdb.test.ts
import { describe, expect, test } from 'bun:test';
import { fetchDetail, searchTitle } from 'packages/obsidian/src/watchlist/tmdb';
function capture(): { calls: { url: string; headers: Record<string, string> }[]; http: any } {
const calls: { url: string; headers: Record<string, string> }[] = [];
return {
calls,
http: async (url: string, headers: Record<string, string>) => {
calls.push({ url, headers });
return { results: [] };
},
};
}
describe('fetchDetail', () => {
test('v4 token → Bearer header, no api_key param', async () => {
const { calls, http } = capture();
await fetchDetail(http, 'eyJhbGciOi.fake.jwt', '693134', true);
expect(calls[0].headers['Authorization']).toBe('Bearer eyJhbGciOi.fake.jwt');
expect(calls[0].url).not.toContain('api_key');
});
test('v3 key → api_key param, no auth header', async () => {
const { calls, http } = capture();
await fetchDetail(http, 'abc123', '693134', true);
expect(calls[0].url).toContain('api_key=abc123');
expect(calls[0].headers['Authorization']).toBeUndefined();
});
test('movie url + append', async () => {
const { calls, http } = capture();
await fetchDetail(http, 'k', '693134', true);
expect(calls[0].url).toContain('/3/movie/693134');
expect(calls[0].url).toContain('append_to_response=credits%2Cexternal_ids%2Crelease_dates%2Cvideos');
expect(calls[0].url).toContain('language=en-US');
});
test('tv url + append', async () => {
const { calls, http } = capture();
await fetchDetail(http, 'k', '84958', false);
expect(calls[0].url).toContain('/3/tv/84958');
expect(calls[0].url).toContain('append_to_response=aggregate_credits%2Cexternal_ids%2Ccontent_ratings%2Cvideos');
});
});
describe('searchTitle', () => {
test('movie search url + year', async () => {
const { calls, http } = capture();
await searchTitle(http, 'k', 'Dune Part Two', true, '2024');
expect(calls[0].url).toContain('/3/search/movie');
expect(calls[0].url).toContain('query=Dune+Part+Two');
expect(calls[0].url).toContain('primary_release_year=2024');
});
test('tv search url', async () => {
const { calls, http } = capture();
await searchTitle(http, 'k', 'Loki', false, '2021');
expect(calls[0].url).toContain('/3/search/tv');
expect(calls[0].url).toContain('first_air_date_year=2021');
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `bun run test 2>&1 | tail -5`
Expected: FAIL — module not found
- [ ] **Step 3: Write implementation**
```ts
// packages/obsidian/src/watchlist/tmdb.ts
export type HttpJsonFn = (url: string, headers: Record<string, string>) => Promise<any>;
const BASE = 'https://api.themoviedb.org/3';
function authParts(key: string): { headers: Record<string, string>; extraParams: Record<string, string> } {
if (key.startsWith('eyJ')) {
return { headers: { Authorization: `Bearer ${key}`, accept: 'application/json' }, extraParams: {} };
}
return { headers: { accept: 'application/json' }, extraParams: { api_key: key } };
}
function buildUrl(path: string, params: Record<string, string>): string {
const qs = new URLSearchParams(params);
return `${BASE}${path}?${qs.toString()}`;
}
export async function fetchDetail(http: HttpJsonFn, key: string, tmdbId: string, isMovie: boolean): Promise<any> {
const { headers, extraParams } = authParts(key);
const path = isMovie ? `/movie/${tmdbId}` : `/tv/${tmdbId}`;
const append = isMovie ? 'credits,external_ids,release_dates,videos' : 'aggregate_credits,external_ids,content_ratings,videos';
return await http(buildUrl(path, { append_to_response: append, language: 'en-US', ...extraParams }), headers);
}
export async function searchTitle(http: HttpJsonFn, key: string, query: string, isMovie: boolean, year?: string): Promise<any[]> {
const { headers, extraParams } = authParts(key);
const params: Record<string, string> = { query, language: 'en-US', ...extraParams };
if (year) params[isMovie ? 'primary_release_year' : 'first_air_date_year'] = year;
const res = await http(buildUrl(isMovie ? '/search/movie' : '/search/tv', params), headers);
return res?.results ?? [];
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `bun run test 2>&1 | tail -5`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add packages/obsidian/src/watchlist/tmdb.ts tests/watchlist-tmdb.test.ts
git commit -m "feat(watchlist): tmdb client — v3/v4 auth, detail + search"
```
---
### Task 6: SyncEngine — iterate, tier, throttle, diff-on-write, watch rule
**Files:**
- Create: `packages/obsidian/src/watchlist/SyncEngine.ts`
- Test: `tests/watchlist-sync-engine.test.ts`
**Interfaces:**
- Consumes: `parseNote`, `extractMyNotes`, `noteTmdbRef` (Task 2); `buildRecord` (Task 3); `renderNote` (Task 4).
- Produces:
```ts
export interface SyncDeps {
listNotes(): Promise<{ path: string; content: string }[]>;
writeNote(path: string, content: string): Promise<void>;
fetchDetail(tmdbId: string, isMovie: boolean): Promise<any>; // throws TmdbRateLimitError on 429
sleep(ms: number): Promise<void>;
log(msg: string): void;
}
export interface SyncOptions { full?: boolean; dryRun?: boolean; throttleMs?: number } // throttleMs default 250
export interface SyncReport { scanned: number; synced: number; written: number; skippedNoId: number; skippedStatic: number; flipped: string[]; errors: { path: string; error: string }[] }
export class TmdbRateLimitError extends Error { retryAfterMs: number }
export async function syncFolder(deps: SyncDeps, opts?: SyncOptions): Promise<SyncReport>
export function isActive(fm: Record<string, string>): boolean
```
Tier rule: `isActive(fm)` = `status` ∈ {`Returning Series`, `In Production`, `Planned`, `Pilot`} OR `watch_status` = `Watching` OR `next_air_date` set (non-null, non-empty) OR `status` missing/empty. Everything else = STATIC → skipped unless `opts.full`.
429 handling: on `TmdbRateLimitError`, `sleep(retryAfterMs)`, retry ONCE; second failure records error for that note and continues.
- [ ] **Step 1: Write the failing test**
```ts
// tests/watchlist-sync-engine.test.ts
import { describe, expect, test } from 'bun:test';
import { syncFolder, isActive, TmdbRateLimitError, type SyncDeps } from 'packages/obsidian/src/watchlist/SyncEngine';
import tvDetail from 'tests/fixtures/tmdb-tv-loki.json';
const ENDED_NOTE = `---
type: watchlist_item
media_type: TV Series
watch_status: Watched
rating: 5
rating_stars: ⭐️⭐️⭐️⭐️⭐️
status: Ended
tmdb_id: 84958
last_air_date: 2023-11-09
---
# Loki
## My Notes
keep me
`;
const AIRING_NOTE = ENDED_NOTE.replace('status: Ended', 'status: Returning Series').replace('last_air_date: 2023-11-09', 'last_air_date: 2023-10-01');
function makeDeps(notes: { path: string; content: string }[], detail: any = tvDetail) {
const writes: { path: string; content: string }[] = [];
const fetches: string[] = [];
const deps: SyncDeps = {
listNotes: async () => notes,
writeNote: async (path, content) => { writes.push({ path, content }); },
fetchDetail: async (id) => { fetches.push(id); return detail; },
sleep: async () => {},
log: () => {},
};
return { deps, writes, fetches };
}
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));
test('next_air_date set → active', () => expect(isActive({ status: 'Ended', next_air_date: '2026-08-01' })).toBe(true));
test('Ended → static', () => expect(isActive({ status: 'Ended', next_air_date: 'null' })).toBe(false));
test('Released movie → static', () => expect(isActive({ status: 'Released' })).toBe(false));
test('missing status → active (needs first enrich)', () => expect(isActive({})).toBe(true));
});
describe('syncFolder', () => {
test('default run skips static notes', async () => {
const { deps, fetches } = makeDeps([{ path: 'Loki.md', content: ENDED_NOTE }]);
const report = await syncFolder(deps);
expect(fetches.length).toBe(0);
expect(report.skippedStatic).toBe(1);
});
test('full run processes static notes', async () => {
const { deps, fetches } = makeDeps([{ path: 'Loki.md', content: ENDED_NOTE }]);
await syncFolder(deps, { full: true });
expect(fetches).toEqual(['84958']);
});
test('diff-on-write: unchanged content not rewritten', async () => {
const { deps, writes } = makeDeps([{ path: 'Loki.md', content: ENDED_NOTE }]);
const first = await syncFolder(deps, { full: true });
expect(first.written).toBe(1);
// second pass with the note the engine just produced → no write
const rendered = (await (async () => { const w: any[] = []; const d2 = { ...deps, listNotes: async () => [{ path: 'Loki.md', content: (await deps as any).lastWrite }] }; return null; })(), null);
// simpler: re-run with the written content
});
test('watch rule flips through full pipeline', async () => {
const { deps, writes } = makeDeps([{ path: 'Loki.md', content: AIRING_NOTE }]);
const report = await syncFolder(deps);
expect(writes.length).toBe(1);
expect(writes[0].content).toContain('watch_status: Unwatched');
expect(report.flipped).toEqual(['Loki.md']);
});
test('My Notes preserved through rewrite', async () => {
const { deps, writes } = makeDeps([{ path: 'Loki.md', content: AIRING_NOTE }]);
await syncFolder(deps);
expect(writes[0].content).toContain('keep me');
});
test('no tmdb ref → skipped, counted', async () => {
const { deps, fetches } = makeDeps([{ path: '_Dashboard.md', content: '# dash' }]);
const report = await syncFolder(deps, { full: true });
expect(fetches.length).toBe(0);
expect(report.skippedNoId).toBe(1);
});
test('dryRun: no writes, report counts', async () => {
const { deps, writes } = makeDeps([{ path: 'Loki.md', content: AIRING_NOTE }]);
const report = await syncFolder(deps, { dryRun: true });
expect(writes.length).toBe(0);
expect(report.written).toBe(1); // counts what WOULD be written
});
test('429 → sleep(retryAfter) then retry succeeds', async () => {
let calls = 0;
const slept: number[] = [];
const deps: SyncDeps = {
listNotes: async () => [{ path: 'Loki.md', content: AIRING_NOTE }],
writeNote: async () => {},
fetchDetail: async () => {
calls++;
if (calls === 1) { const e = new TmdbRateLimitError('429'); e.retryAfterMs = 1500; throw e; }
return tvDetail;
},
sleep: async ms => { slept.push(ms); },
log: () => {},
};
const report = await syncFolder(deps);
expect(calls).toBe(2);
expect(slept).toContain(1500);
expect(report.errors.length).toBe(0);
});
test('fetch error recorded, other notes continue', async () => {
const { deps } = makeDeps([
{ path: 'Bad.md', content: AIRING_NOTE },
{ path: 'Good.md', content: AIRING_NOTE },
]);
let n = 0;
deps.fetchDetail = async () => { n++; if (n === 1) throw new Error('boom'); return tvDetail; };
const report = await syncFolder(deps);
expect(report.errors.length).toBe(1);
expect(report.errors[0].path).toBe('Bad.md');
expect(report.synced).toBe(1);
});
});
```
NOTE for implementer: replace the malformed `diff-on-write` test above with this working version:
```ts
test('diff-on-write: second pass on rendered output writes nothing', async () => {
const { deps, writes } = makeDeps([{ path: 'Loki.md', content: ENDED_NOTE }]);
await syncFolder(deps, { full: true });
const rendered = writes[0].content;
const second = makeDeps([{ path: 'Loki.md', content: rendered }]);
const report = await syncFolder(second.deps, { full: true });
expect(second.writes.length).toBe(0);
expect(report.written).toBe(0);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `bun run test 2>&1 | tail -5`
Expected: FAIL — module not found
- [ ] **Step 3: Write implementation**
```ts
// packages/obsidian/src/watchlist/SyncEngine.ts
import { parseNote, extractMyNotes, noteTmdbRef } from 'packages/obsidian/src/watchlist/parse';
import { buildRecord } from 'packages/obsidian/src/watchlist/build';
import { renderNote } from 'packages/obsidian/src/watchlist/render';
export interface SyncDeps {
listNotes(): Promise<{ path: string; content: string }[]>;
writeNote(path: string, content: string): Promise<void>;
fetchDetail(tmdbId: string, isMovie: boolean): Promise<any>;
sleep(ms: number): Promise<void>;
log(msg: string): void;
}
export interface SyncOptions {
full?: boolean;
dryRun?: boolean;
throttleMs?: number;
}
export interface SyncReport {
scanned: number;
synced: number;
written: number;
skippedNoId: number;
skippedStatic: number;
flipped: string[];
errors: { path: string; error: string }[];
}
export class TmdbRateLimitError extends Error {
retryAfterMs: number = 2000;
}
const ACTIVE_STATUSES = new Set(['Returning Series', 'In Production', 'Planned', 'Pilot']);
function strip(s: string | undefined): string {
return (s ?? '').trim().replace(/^"|"$/g, '');
}
export function isActive(fm: Record<string, string>): boolean {
const status = strip(fm['status']);
if (!status) return true; // never enriched → needs first pass
if (ACTIVE_STATUSES.has(status)) return true;
if (strip(fm['watch_status']) === 'Watching') return true;
const nextAir = strip(fm['next_air_date']);
if (nextAir && nextAir !== 'null') return true;
return false;
}
export async function syncFolder(deps: SyncDeps, opts: SyncOptions = {}): Promise<SyncReport> {
const throttleMs = opts.throttleMs ?? 250;
const report: SyncReport = { scanned: 0, synced: 0, written: 0, skippedNoId: 0, skippedStatic: 0, flipped: [], errors: [] };
const notes = await deps.listNotes();
for (const note of notes) {
report.scanned++;
const { frontmatter, body } = parseNote(note.content);
const ref = noteTmdbRef(frontmatter);
if (!ref) {
report.skippedNoId++;
continue;
}
if (!opts.full && !isActive(frontmatter)) {
report.skippedStatic++;
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 record = buildRecord(detail, ref.isMovie, frontmatter);
const rendered = renderNote(record, extractMyNotes(body));
report.synced++;
if (strip(frontmatter['watch_status']) === 'Watched' && record.watchStatus === 'Unwatched') {
report.flipped.push(note.path);
}
if (rendered !== note.content) {
report.written++;
if (!opts.dryRun) await deps.writeNote(note.path, rendered);
deps.log(`${opts.dryRun ? '[dry] ' : ''}updated ${note.path}`);
}
await deps.sleep(throttleMs);
} catch (e) {
report.errors.push({ path: note.path, error: e instanceof Error ? e.message : String(e) });
deps.log(`ERROR ${note.path}: ${String(e)}`);
}
}
return report;
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `bun run test 2>&1 | tail -5`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add packages/obsidian/src/watchlist/SyncEngine.ts tests/watchlist-sync-engine.test.ts
git commit -m "feat(watchlist): SyncEngine — tiered diff-on-write sync with throttle/backoff"
```
---
### Task 7: Settings + plugin wiring (commands, interval, catch-up)
**Files:**
- Modify: `packages/obsidian/src/settings/Settings.ts` — add fields to `MediaDbPluginSettings` (~L52-132), defaults to `DEFAULT_SETTINGS` (~L311), UI section in `MediaDbSettingTab.display()` (~L442)
- Modify: `packages/obsidian/src/main.ts` — commands in `registerCommands()` (~L103), interval in `onload()` (~L48)
- Create: `packages/obsidian/src/watchlist/WatchlistController.ts` (vault adapter + scheduler glue)
- Test: `tests/watchlist-controller.test.ts`
**Interfaces:**
- Consumes: `syncFolder`, `SyncDeps` (Task 6); `fetchDetail` (Task 5); existing `plugin.settings`, `app.vault`, `obsidianFetch` (`packages/obsidian/src/utils/Utils.ts:283`), secretStorage pattern from `TMDBMovieAPI.ts:64`.
- Produces: `WatchlistController` with `syncNow(full: boolean, dryRun: boolean): Promise<SyncReport>`, `maybeCatchUp(): Promise<void>`, `resolveMissingIds(dryRun: boolean): Promise<void>` (impl Task 8).
New settings fields (add to interface + defaults):
```ts
watchlistEnabled: boolean; // default false
watchlistFolder: string; // default 'Watchlist'
watchlistSyncIntervalHours: number; // default 24
watchlistLastSync: number; // epoch ms, default 0 — updated after each successful scheduled sync
```
Settings UI (new "Watchlist sync" group in `display()`): toggle `watchlistEnabled`, text `watchlistFolder`, slider/text `watchlistSyncIntervalHours` (1168). TMDB key reuses the existing TMDB secret (`settings.TMDBKeyId` via secretStorage) — no new key field.
`WatchlistController` core:
```ts
// packages/obsidian/src/watchlist/WatchlistController.ts
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 { obsidianFetch } from 'packages/obsidian/src/utils/Utils';
export class WatchlistController {
constructor(private plugin: MediaDbPlugin) {}
private async getKey(): Promise<string> {
const keyId = this.plugin.settings.TMDBKeyId;
const key = keyId ? await this.plugin.app.secretStorage.getSecret(keyId) : '';
if (!key) throw new Error('TMDB API key not configured (Media DB Sync settings).');
return key;
}
private makeDeps(key: string): SyncDeps {
const { app } = this.plugin;
const folder = app.vault.getAbstractFileByPath(this.plugin.settings.watchlistFolder);
return {
listNotes: async () => {
if (!(folder instanceof TFolder)) throw new Error(`Watchlist folder not found: ${this.plugin.settings.watchlistFolder}`);
const files = folder.children.filter((f): f is TFile => f instanceof TFile && f.extension === 'md' && !f.name.startsWith('_'));
const out: { path: string; content: string }[] = [];
for (const f of files) out.push({ path: f.path, content: await app.vault.read(f) });
return out;
},
writeNote: async (path, content) => {
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({ url, headers });
if (res.status === 429) {
const err = new TmdbRateLimitError('TMDB 429');
const ra = Number(res.headers?.['retry-after'] ?? 2);
err.retryAfterMs = ra * 1000;
throw err;
}
if (res.status !== 200) throw new Error(`TMDB ${res.status} for ${url}`);
return res.json;
};
return await fetchDetail(http, tmdbId, isMovie, key === undefined ? '' : key);
},
sleep: ms => new Promise(r => setTimeout(r, ms)),
log: msg => console.log(`[media-db-sync] ${msg}`),
};
}
async syncNow(full: boolean, dryRun = false): Promise<SyncReport> {
const key = await this.getKey();
const report = await syncFolder(this.makeDeps(key), { full, dryRun });
const mode = dryRun ? 'DRY-RUN ' : '';
new Notice(
`Watchlist ${mode}sync: ${report.synced} checked, ${report.written} updated, ${report.flipped.length} flipped to Unwatched` +
(report.errors.length ? `, ${report.errors.length} errors (see console)` : ''),
);
if (!dryRun) {
this.plugin.settings.watchlistLastSync = Date.now();
await this.plugin.saveSettings();
}
return report;
}
async maybeCatchUp(): Promise<void> {
const s = this.plugin.settings;
if (!s.watchlistEnabled) return;
const due = s.watchlistLastSync + s.watchlistSyncIntervalHours * 3600_000;
if (Date.now() >= due) {
await this.syncNow(false).catch(e => console.error('[media-db-sync] scheduled sync failed', e));
}
}
}
```
NOTE for implementer: check exact `obsidianFetch` signature/return at `packages/obsidian/src/utils/Utils.ts:283` and the exact secretStorage call shape at `packages/obsidian/src/api/apis/TMDBMovieAPI.ts:62-90` before writing — mirror what the codebase actually does (incl. how non-200s and headers surface). Fix the `fetchDetail(http, tmdbId, isMovie, key…)` argument order to match Task 5's signature `fetchDetail(http, key, tmdbId, isMovie)`.
`main.ts` wiring (inside `onload()` after settings load):
```ts
this.watchlist = new WatchlistController(this);
// catch-up shortly after startup (let vault index settle), then hourly due-check
window.setTimeout(() => void this.watchlist.maybeCatchUp(), 30_000);
this.registerInterval(window.setInterval(() => void this.watchlist.maybeCatchUp(), 3600_000));
```
Commands (inside `registerCommands()`):
```ts
this.addCommand({
id: 'watchlist-sync-now',
name: 'Watchlist: sync now (airing/active only)',
callback: () => void this.watchlist.syncNow(false),
});
this.addCommand({
id: 'watchlist-sync-full',
name: 'Watchlist: full sync (all entries)',
callback: () => void this.watchlist.syncNow(true),
});
this.addCommand({
id: 'watchlist-sync-dry-run',
name: 'Watchlist: dry-run full sync (log only, no writes)',
callback: () => void this.watchlist.syncNow(true, true),
});
```
- [ ] **Step 1: Write failing controller test**`tests/watchlist-controller.test.ts` covering `maybeCatchUp` due-time logic with a fake plugin object (settings + stubbed `syncNow`): overdue → calls sync; not due → no call; disabled → no call.
```ts
// tests/watchlist-controller.test.ts
import { describe, expect, test } from 'bun:test';
import { WatchlistController } from 'packages/obsidian/src/watchlist/WatchlistController';
function fakePlugin(overrides: Partial<{ enabled: boolean; last: number; hours: number }> = {}) {
return {
settings: {
watchlistEnabled: overrides.enabled ?? true,
watchlistLastSync: overrides.last ?? 0,
watchlistSyncIntervalHours: overrides.hours ?? 24,
watchlistFolder: 'Watchlist',
TMDBKeyId: 'kid',
},
saveSettings: async () => {},
app: {},
} as any;
}
describe('maybeCatchUp', () => {
test('overdue → syncs', async () => {
const c = new WatchlistController(fakePlugin({ last: 0 }));
let called = false;
(c as any).syncNow = async () => { called = true; return {} as any; };
await c.maybeCatchUp();
expect(called).toBe(true);
});
test('recent sync → no call', async () => {
const c = new WatchlistController(fakePlugin({ last: Date.now() }));
let called = false;
(c as any).syncNow = async () => { called = true; return {} as any; };
await c.maybeCatchUp();
expect(called).toBe(false);
});
test('disabled → no call', async () => {
const c = new WatchlistController(fakePlugin({ enabled: false, last: 0 }));
let called = false;
(c as any).syncNow = async () => { called = true; return {} as any; };
await c.maybeCatchUp();
expect(called).toBe(false);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `bun run test 2>&1 | tail -5`
Expected: FAIL — module not found
- [ ] **Step 3: Implement** `WatchlistController.ts` (code above, with obsidianFetch/secretStorage shapes verified against the codebase), settings fields + defaults + UI group, `main.ts` commands + interval + `watchlist` property.
- [ ] **Step 4: Run all gates**
Run: `bun run test 2>&1 | tail -5` → PASS; `bun run tsc` → exit 0; `bun run build 2>&1 | tail -3` → built.
- [ ] **Step 5: Commit**
```bash
git add packages/obsidian/src/watchlist/WatchlistController.ts packages/obsidian/src/settings/Settings.ts packages/obsidian/src/main.ts tests/watchlist-controller.test.ts
git commit -m "feat(watchlist): settings, commands, catch-up scheduler wiring"
```
---
### Task 8: Resolve-missing-ids command
**Files:**
- Create: `packages/obsidian/src/watchlist/resolve.ts`
- Modify: `packages/obsidian/src/watchlist/WatchlistController.ts` (add `resolveMissingIds`)
- Modify: `packages/obsidian/src/main.ts` (add command)
- Test: `tests/watchlist-resolve.test.ts`
**Interfaces:**
- Consumes: `searchTitle` (Task 5), `parseNote` (Task 2).
- Produces: `resolveNote(fm: Record<string, string>, filename: string, search: (q: string, isMovie: boolean, year?: string) => Promise<any[]>): Promise<{ tmdbId: string; isMovie: boolean; matchedTitle: string } | null>`.
Rules: candidate query = `title` field else filename minus `.md`. `year` hint = leading 4 digits of `year`/`release_date` if present. `media_type` field decides movie vs tv; missing → try movie first, then tv. Accept top result ONLY if its title (or original title) case-insensitively equals the query, OR it is the only result. Otherwise return null (log for manual fix) — no guessing.
- [ ] **Step 1: Write the failing test**
```ts
// tests/watchlist-resolve.test.ts
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');
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `bun run test 2>&1 | tail -5`
Expected: FAIL — module not found
- [ ] **Step 3: Write implementation**
```ts
// packages/obsidian/src/watchlist/resolve.ts
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));
}
```
Controller addition (`WatchlistController.resolveMissingIds(dryRun)`): list notes → for each with NO `noteTmdbRef` AND `type` missing or `watchlist_item` (skip `_`-prefixed, skip `type: list` dashboards) → `resolveNote` with `searchTitle` bound to key/http → on hit: patch `tmdb_id` + `media_type` into frontmatter (regex insert after `type:` line or prepend fm block if absent) and log; on null: log "ambiguous/no match". Notice summary at end. Command id `watchlist-resolve-ids`, name `Watchlist: resolve missing TMDB ids`. Next scheduled/manual sync then enriches them (missing `status` → ACTIVE tier → picked up automatically).
- [ ] **Step 4: Run all gates**
Run: `bun run test 2>&1 | tail -5` → PASS; `bun run tsc` → 0; `bun run build 2>&1 | tail -3` → built.
- [ ] **Step 5: Commit**
```bash
git add packages/obsidian/src/watchlist/resolve.ts packages/obsidian/src/watchlist/WatchlistController.ts packages/obsidian/src/main.ts tests/watchlist-resolve.test.ts
git commit -m "feat(watchlist): resolve-missing-ids command"
```
---
### Task 9: Real-vault dry-run verification
**Files:**
- No new source. Verification against `/Users/AfiqZudinHadi/Documents/ai_brain/02 - Areas/Interests/Watchlist/` (568 notes; ALL have tmdb_id — verified 2026-07-29; 5 extra non-entry files skip via no-id path).
- [ ] **Step 1: Commit vault state**`cd ~/Documents/ai_brain && git add -A && git commit -m "pre-sync snapshot"` (revert point).
- [ ] **Step 2: Install dev build** — copy `dist/main.js`, `dist/styles.css`, `manifest.json` into `~/Documents/ai_brain/.obsidian/plugins/media-db-sync/`, enable in Obsidian, set folder `02 - Areas/Interests/Watchlist`, confirm TMDB key present.
- [ ] **Step 3: Run `Watchlist: dry-run full sync`** — read console log. Expect: 568 checked, plausible update count (formatting normalization, e.g. `tmdb_rating 7.0→7`, is expected on first pass), 0 errors, no vault modifications (`git status` clean).
- [ ] **Step 4: Review a sample of logged diffs** — user (Afiq) approves before any live run. STOP here for user sign-off.
- [ ] **Step 5: Live full sync** — run `Watchlist: full sync`, then `git diff --stat` in vault; spot-check: an airing series updated, an Ended one byte-identical (or normalized once), `## My Notes` intact everywhere (`git diff -U0 | grep -c 'My Notes'` → 0 content changes under that heading).
- [ ] **Step 6: Verify in Obsidian Base**`Watchlist.base` tabs render; `next_air_date`/`last_episode` fresh on airing series.
- [ ] **Step 7: Commit fork** — final state, push branch.
---
## Self-Review Notes
- Spec coverage: schema fields (SPEC.md) → Tasks 3/4 incl. `seasons`/`episodes`/`vod` (absent from python reference, present in real vault — always emit, movies `null`/`null`/`[]`). Sync + tiering + throttle → Task 6. Catch-up scheduler → Task 7. Watch rule → Task 3 (logic) + 6 (report). Resolve → Task 8. v3/v4 auth → Task 5. Preserve fields → Tasks 3/6 tests. Dry-run → Tasks 6/7/9.
- Known deliberate deviations from python: comma added to `yamlScalar` quoting regex (matches real vault output); `seasons`/`episodes`/`vod` added; TS number formatting may normalize `x.0` ratings (one-time diff, reviewed in Task 9 dry-run).
- Type consistency: `WatchlistRecord` field names used identically in Tasks 3, 4, 6. `fetchDetail(http, key, tmdbId, isMovie)` — Task 7 code block has an arg-order bug flagged in its NOTE; implementer must match Task 5.
- Phase 2 (manga/books/games) intentionally out of scope.