From 51d630fcd67de7a675eb05426b5890ed2e7678f8 Mon Sep 17 00:00:00 2001 From: afiqzudinhadi Date: Wed, 29 Jul 2026 23:15:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(watchlist):=20buildRecord=20=E2=80=94=20TM?= =?UTF-8?q?DB=20detail=20=E2=86=92=20canonical=20record?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port pure fn mapping raw TMDB JSON + prev frontmatter → WatchlistRecord. Fixtures from python reference selftest. TDD: 14 new cases all green. --- packages/obsidian/src/watchlist/build.ts | 162 +++++++++++++++++++++++ tests/fixtures/tmdb-movie-dune2.json | 81 ++++++++++++ tests/fixtures/tmdb-tv-loki.json | 80 +++++++++++ tests/watchlist-build.test.ts | 112 ++++++++++++++++ 4 files changed, 435 insertions(+) create mode 100644 packages/obsidian/src/watchlist/build.ts create mode 100644 tests/fixtures/tmdb-movie-dune2.json create mode 100644 tests/fixtures/tmdb-tv-loki.json create mode 100644 tests/watchlist-build.test.ts diff --git a/packages/obsidian/src/watchlist/build.ts b/packages/obsidian/src/watchlist/build.ts new file mode 100644 index 0000000..2b3744c --- /dev/null +++ b/packages/obsidian/src/watchlist/build.ts @@ -0,0 +1,162 @@ +import type { WatchlistRecord } from 'packages/obsidian/src/watchlist/schema'; + +export type TmdbDetail = Record; + +const IMG_BASE = 'https://image.tmdb.org/t/p/original'; + +const LANG_FALLBACK: Record = { + 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[] { + const seen = new Set(); + 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): 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(', '), + }; +} diff --git a/tests/fixtures/tmdb-movie-dune2.json b/tests/fixtures/tmdb-movie-dune2.json new file mode 100644 index 0000000..72d5e3c --- /dev/null +++ b/tests/fixtures/tmdb-movie-dune2.json @@ -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" + } + ] + } +} diff --git a/tests/fixtures/tmdb-tv-loki.json b/tests/fixtures/tmdb-tv-loki.json new file mode 100644 index 0000000..0e6085a --- /dev/null +++ b/tests/fixtures/tmdb-tv-loki.json @@ -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 +} diff --git a/tests/watchlist-build.test.ts b/tests/watchlist-build.test.ts new file mode 100644 index 0000000..14389a4 --- /dev/null +++ b/tests/watchlist-build.test.ts @@ -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(''); + }); +});