feat(watchlist): buildRecord — TMDB detail → canonical record
Port pure fn mapping raw TMDB JSON + prev frontmatter → WatchlistRecord. Fixtures from python reference selftest. TDD: 14 new cases all green.
This commit is contained in:
parent
732ec8adf6
commit
51d630fcd6
4 changed files with 435 additions and 0 deletions
162
packages/obsidian/src/watchlist/build.ts
Normal file
162
packages/obsidian/src/watchlist/build.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
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(', '),
|
||||||
|
};
|
||||||
|
}
|
||||||
81
tests/fixtures/tmdb-movie-dune2.json
vendored
Normal file
81
tests/fixtures/tmdb-movie-dune2.json
vendored
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
{
|
||||||
|
"id": 693134,
|
||||||
|
"title": "Dune: Part Two",
|
||||||
|
"original_title": "Dune: Part Two",
|
||||||
|
"original_language": "en",
|
||||||
|
"spoken_languages": [
|
||||||
|
{
|
||||||
|
"iso_639_1": "en",
|
||||||
|
"english_name": "English"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"overview": "Paul Atreides unites with the Fremen...",
|
||||||
|
"runtime": 167,
|
||||||
|
"status": "Released",
|
||||||
|
"release_date": "2024-02-27",
|
||||||
|
"vote_average": 8.1,
|
||||||
|
"homepage": "https://www.dunemovie.com",
|
||||||
|
"poster_path": "/1pdfLvkbY9ohJlCjQH2CZjjYVvJ.jpg",
|
||||||
|
"genres": [
|
||||||
|
{
|
||||||
|
"name": "Science Fiction"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Adventure"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"production_countries": [
|
||||||
|
{
|
||||||
|
"name": "United States of America"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"credits": {
|
||||||
|
"cast": [
|
||||||
|
{
|
||||||
|
"name": "Timothée Chalamet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Zendaya"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"crew": [
|
||||||
|
{
|
||||||
|
"job": "Director",
|
||||||
|
"name": "Denis Villeneuve"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job": "Screenplay",
|
||||||
|
"name": "Jon Spaihts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job": "Producer",
|
||||||
|
"name": "Mary Parent"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"external_ids": {
|
||||||
|
"imdb_id": "tt15239678"
|
||||||
|
},
|
||||||
|
"release_dates": {
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"iso_3166_1": "US",
|
||||||
|
"release_dates": [
|
||||||
|
{
|
||||||
|
"certification": "PG-13"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"videos": {
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"site": "YouTube",
|
||||||
|
"type": "Trailer",
|
||||||
|
"official": true,
|
||||||
|
"key": "Way9Dexny3w"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
80
tests/fixtures/tmdb-tv-loki.json
vendored
Normal file
80
tests/fixtures/tmdb-tv-loki.json
vendored
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
112
tests/watchlist-build.test.ts
Normal file
112
tests/watchlist-build.test.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue