Add movies & TV tracker: movies schema, Netflix/Letterboxd/Plex/JSON/recs importers, TMDB matching, stats, owner visibility
This commit is contained in:
@@ -29,3 +29,4 @@ export * from './dataholders';
|
||||
export * from './stats';
|
||||
export * from './pubg-map';
|
||||
export * from './ai';
|
||||
export * from './movies';
|
||||
|
||||
180
src/lib/server/db/schema/movies.ts
Normal file
180
src/lib/server/db/schema/movies.ts
Normal 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')`)]
|
||||
);
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user