Add movies & TV tracker: movies schema, Netflix/Letterboxd/Plex/JSON/recs importers, TMDB matching, stats, owner visibility

This commit is contained in:
2026-10-26 00:31:23 +00:00
parent 75571bbfe0
commit 71e015d1d0
28 changed files with 42754 additions and 14 deletions

View File

@@ -29,3 +29,4 @@ export * from './dataholders';
export * from './stats';
export * from './pubg-map';
export * from './ai';
export * from './movies';

View File

@@ -0,0 +1,180 @@
import { sql } from 'drizzle-orm';
import { check, date, index, integer, real, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { moviesSchema } from './schemas';
import { id, timestamps, timestamptz } from './helpers';
// The movies & TV tracker (/trackers/movies). Rows arrive through the idempotent import scripts
// (scripts/movies-import.ts for Netflix, Plex, Letterboxd and manual JSON; scripts/movies-recs-import.ts
// for Ju's recommendations) plus the owner's edit forms. TMDB metadata (posters, genres, runtime,
// cast) is cached on the row by scripts/movies-tmdb.ts, so pages never call TMDB.
// One row per title, deduped by (type, tmdb_id) once matched, else by match_key.
export const MOVIE_TYPES = ['movie', 'show'] as const;
export type MovieType = (typeof MOVIE_TYPES)[number];
export const MOVIE_STATUSES = ['watching', 'watched', 'watchlist', 'dropped'] as const;
export type MovieStatus = (typeof MOVIE_STATUSES)[number];
export const MOVIE_WHO = ['stan', 'juhee', 'both'] as const;
export type MovieWho = (typeof MOVIE_WHO)[number];
export const MOVIE_PLATFORMS = ['netflix', 'plex', 'cinema', 'other'] as const;
export type MoviePlatform = (typeof MOVIE_PLATFORMS)[number];
export const MOVIE_SOURCES = ['netflix', 'plex', 'letterboxd', 'manual', 'ju', 'site'] as const;
export type MovieSource = (typeof MOVIE_SOURCES)[number];
/** pending = not tried yet; matched = TMDB id set by the matcher; manual = id given by the source. */
export const MOVIE_MATCH = ['pending', 'matched', 'manual', 'not_found'] as const;
export const movieTitles = moviesSchema.table(
'titles',
{
id: id(),
type: text('type').notNull(),
tmdbId: integer('tmdb_id'),
/** Normalised `type:title:year` for rows without a TMDB id (and as a merge hint). */
matchKey: text('match_key').notNull(),
title: text('title').notNull(),
originalTitle: text('original_title'),
year: integer('year'),
status: text('status').notNull().default('watched'),
rating: real('rating'),
rewatchCount: integer('rewatch_count').notNull().default(0),
firstWatched: date('first_watched'),
lastWatched: date('last_watched'),
watchedBy: text('watched_by'),
platform: text('platform'),
/** Where the row first came from; `sources` lists every source that mentioned it. */
source: text('source').notNull(),
sources: text('sources').array().notNull().default([]),
notes: text('notes'),
// TMDB metadata cache.
matchStatus: text('match_status').notNull().default('pending'),
posterPath: text('poster_path'),
backdropPath: text('backdrop_path'),
overview: text('overview'),
genres: text('genres').array().notNull().default([]),
cast: text('cast').array().notNull().default([]),
/** Movie runtime, or the typical episode runtime for shows (minutes). */
runtimeMin: integer('runtime_min'),
totalSeasons: integer('total_seasons'),
totalEpisodes: integer('total_episodes'),
metadataAt: timestamptz('metadata_at'),
...timestamps
},
(t) => [
uniqueIndex('titles_tmdb_key')
.on(t.type, t.tmdbId)
.where(sql`${t.tmdbId} IS NOT NULL`),
uniqueIndex('titles_match_key').on(t.matchKey),
index('titles_status_idx').on(t.status),
check('titles_type_check', sql`${t.type} IN ('movie','show')`),
check('titles_status_check', sql`${t.status} IN ('watching','watched','watchlist','dropped')`),
check(
'titles_rating_check',
sql`${t.rating} IS NULL OR (${t.rating} >= 0.5 AND ${t.rating} <= 5 AND ${t.rating} * 2 = round(${t.rating} * 2))`
),
check('titles_rewatch_check', sql`${t.rewatchCount} >= 0`),
check(
'titles_who_check',
sql`${t.watchedBy} IS NULL OR ${t.watchedBy} IN ('stan','juhee','both')`
),
check(
'titles_platform_check',
sql`${t.platform} IS NULL OR ${t.platform} IN ('netflix','plex','cinema','other')`
),
check(
'titles_source_check',
sql`${t.source} IN ('netflix','plex','letterboxd','manual','ju','site')`
),
check(
'titles_match_check',
sql`${t.matchStatus} IN ('pending','matched','manual','not_found')`
),
check('titles_title_check', sql`length(btrim(${t.title})) > 0`)
]
);
/** Episodes of a show: `ep_key` is `s<season>e<episode>` when numbered, else `s<season>:<name>`. */
export const movieEpisodes = moviesSchema.table(
'episodes',
{
id: id(),
titleId: integer('title_id')
.notNull()
.references(() => movieTitles.id, { onDelete: 'cascade' }),
epKey: text('ep_key').notNull(),
season: integer('season'),
episode: integer('episode'),
name: text('name'),
runtimeMin: integer('runtime_min'),
...timestamps
},
(t) => [
uniqueIndex('episodes_title_key').on(t.titleId, t.epKey),
index('episodes_title_idx').on(t.titleId, t.season, t.episode)
]
);
/** One viewing (a day's plays of a movie or an episode). `source_key` makes imports idempotent. */
export const moviePlays = moviesSchema.table(
'plays',
{
id: id(),
titleId: integer('title_id')
.notNull()
.references(() => movieTitles.id, { onDelete: 'cascade' }),
episodeId: integer('episode_id').references(() => movieEpisodes.id, { onDelete: 'cascade' }),
watchedOn: date('watched_on').notNull(),
watchedAt: timestamptz('watched_at'),
/** Minutes actually watched when the source says so (Netflix, Plex); else runtime is used. */
durationMin: integer('duration_min'),
who: text('who').notNull().default('stan'),
platform: text('platform'),
source: text('source').notNull(),
sourceKey: text('source_key').notNull(),
...timestamps
},
(t) => [
uniqueIndex('plays_source_key').on(t.sourceKey),
index('plays_title_idx').on(t.titleId),
index('plays_watched_idx').on(t.watchedOn),
check('plays_who_check', sql`${t.who} IN ('stan','juhee','both')`),
check(
'plays_platform_check',
sql`${t.platform} IS NULL OR ${t.platform} IN ('netflix','plex','cinema','other')`
)
]
);
/** "Ju recommends": Ju's picks, with TMDB metadata cached like titles. */
export const movieRecs = moviesSchema.table(
'recs',
{
id: id(),
tmdbId: integer('tmdb_id').notNull(),
type: text('type').notNull(),
title: text('title').notNull(),
forWhom: text('for_whom').notNull(),
reason: text('reason'),
addedAt: timestamptz('added_at').notNull().defaultNow(),
posterPath: text('poster_path'),
year: integer('year'),
genres: text('genres').array().notNull().default([]),
metadataAt: timestamptz('metadata_at'),
...timestamps
},
(t) => [
uniqueIndex('recs_tmdb_key').on(t.type, t.tmdbId),
check('recs_type_check', sql`${t.type} IN ('movie','show')`),
check('recs_for_check', sql`${t.forWhom} IN ('stan','juhee','both')`),
check('recs_title_check', sql`length(btrim(${t.title})) > 0`)
]
);
/** Tracker settings (key/value). `visibility` = public | private (public by default). */
export const movieSettings = moviesSchema.table(
'settings',
{
key: text('key').primaryKey(),
value: text('value').notNull(),
...timestamps
},
(t) => [check('settings_key_check', sql`${t.key} IN ('visibility')`)]
);

View File

@@ -26,3 +26,4 @@ export const trainsSchema = pgSchema('trains');
export const dataholdersSchema = pgSchema('dataholders');
export const statsSchema = pgSchema('stats');
export const aiSchema = pgSchema('ai');
export const moviesSchema = pgSchema('movies');