Add movies & TV tracker: movies schema, Netflix/Letterboxd/Plex/JSON/recs importers, TMDB matching, stats, owner visibility
This commit is contained in:
@@ -39,6 +39,7 @@
|
||||
isOwner: boolean;
|
||||
version: string;
|
||||
inactiveGames?: string[];
|
||||
hiddenTrackers?: string[];
|
||||
};
|
||||
let { data, children }: { data: ChromeData; children: Snippet } = $props();
|
||||
const showFooter = $derived(!hidesFooter(page.url.pathname));
|
||||
@@ -66,7 +67,10 @@
|
||||
};
|
||||
const menus: Record<string, { label: string; items: readonly MenuItem[] }> = $derived({
|
||||
'/projects': { label: 'projects', items: projects },
|
||||
'/trackers': { label: 'trackers', items: visibleTrackers(data.isOwner, data.inactiveGames) },
|
||||
'/trackers': {
|
||||
label: 'trackers',
|
||||
items: visibleTrackers(data.isOwner, data.inactiveGames, data.hiddenTrackers)
|
||||
},
|
||||
'/stats': { label: 'stats', items: statsPages }
|
||||
});
|
||||
const socialLinks = [
|
||||
|
||||
@@ -351,6 +351,13 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
|
||||
external: false,
|
||||
private: false
|
||||
},
|
||||
{
|
||||
name: 'movies',
|
||||
subtitle: 'movies and TV shows we watch: Netflix, Plex and cinema, ratings and stats',
|
||||
href: '/trackers/movies',
|
||||
external: false,
|
||||
private: false
|
||||
},
|
||||
{
|
||||
name: 'pc',
|
||||
subtitle: 'my PC setup: every part, my devices, the next build and mouse sensitivities',
|
||||
@@ -447,12 +454,19 @@ export const privateTrackers: readonly Tracker[] = allTrackers.filter((t) => t.p
|
||||
const ownerTrackers: readonly Tracker[] = allTrackers.map(strip);
|
||||
|
||||
/** Trackers for the header, home, /trackers, /start and error pages; with `inactiveGames` (root
|
||||
* layout data) the games group lists active games first and flags the rest `inactive`. */
|
||||
* layout data) the games group lists active games first and flags the rest `inactive`.
|
||||
* `hidden` (root layout `hiddenTrackers`, e.g. movies when its owner switch is private) drops
|
||||
* those trackers for everyone else and gives them a lock for the owner. */
|
||||
export function visibleTrackers(
|
||||
isOwner: boolean,
|
||||
inactiveGames: readonly string[] = []
|
||||
inactiveGames: readonly string[] = [],
|
||||
hidden: readonly string[] = []
|
||||
): readonly Tracker[] {
|
||||
const list = isOwner ? ownerTrackers : trackers;
|
||||
let list = isOwner ? ownerTrackers : trackers;
|
||||
if (hidden.length)
|
||||
list = isOwner
|
||||
? list.map((t) => (hidden.includes(t.name) ? { ...t, locked: true } : t))
|
||||
: list.filter((t) => !hidden.includes(t.name));
|
||||
if (!inactiveGames.length) return list;
|
||||
const games = sortedGames(inactiveGames);
|
||||
return list.map((t) => (t.children === gameTrackers ? { ...t, children: games } : t));
|
||||
|
||||
@@ -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');
|
||||
|
||||
224
src/lib/server/movies/parse.test.ts
Normal file
224
src/lib/server/movies/parse.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
epKey,
|
||||
fromManual,
|
||||
guessDateOrder,
|
||||
manualFile,
|
||||
matchKey,
|
||||
normTitle,
|
||||
parseCsv,
|
||||
parseLetterboxd,
|
||||
parseNetflix,
|
||||
parsePlex,
|
||||
parseWhoMap,
|
||||
plexRows,
|
||||
recsFile,
|
||||
splitNetflixTitle,
|
||||
tmdbFromGuids
|
||||
} from './parse';
|
||||
|
||||
const ACTIVITY_HEAD =
|
||||
'Profile Name,Start Time,Duration,Attributes,Title,Supplemental Video Type,Device Type,Bookmark,Latest Bookmark,Country';
|
||||
|
||||
describe('titles', () => {
|
||||
it('normalises titles and keys', () => {
|
||||
expect(normTitle('Amélie & Co.: The “Return”')).toBe('amelie and co the return');
|
||||
expect(matchKey('movie', 'Past Lives', 2023)).toBe('movie:past lives:2023');
|
||||
expect(epKey({ season: 1, episode: 3, name: 'x' })).toBe('s1e3');
|
||||
expect(epKey({ season: 2, episode: null, name: 'Bread & Lottery' })).toBe(
|
||||
's2:bread and lottery'
|
||||
);
|
||||
});
|
||||
it('splits Netflix show titles, keeps colon movies', () => {
|
||||
const none = new Set<string>();
|
||||
expect(
|
||||
splitNetflixTitle('Stranger Things: Season 1: Chapter One: The Vanishing (Episode 1)', none)
|
||||
).toEqual({
|
||||
type: 'show',
|
||||
title: 'Stranger Things',
|
||||
season: 1,
|
||||
episode: 1,
|
||||
name: 'Chapter One: The Vanishing'
|
||||
});
|
||||
expect(splitNetflixTitle('De Luizenmoeder: Seizoen 2: Aflevering', none)).toMatchObject({
|
||||
type: 'show',
|
||||
title: 'De Luizenmoeder',
|
||||
season: 2
|
||||
});
|
||||
expect(splitNetflixTitle('Glass Onion: A Knives Out Mystery', none)).toEqual({
|
||||
type: 'movie',
|
||||
title: 'Glass Onion: A Knives Out Mystery'
|
||||
});
|
||||
expect(splitNetflixTitle('Our Planet: Frozen Worlds', new Set(['our planet']))).toMatchObject({
|
||||
type: 'show',
|
||||
title: 'Our Planet',
|
||||
name: 'Frozen Worlds'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('netflix', () => {
|
||||
it('parses ViewingActivity: sessions, trailers, short plays, profiles', () => {
|
||||
const csv = [
|
||||
ACTIVITY_HEAD,
|
||||
'Stan,2024-04-10 20:00:00,01:20:00,,Glass Onion: A Knives Out Mystery,,TV,,,NL',
|
||||
// continues after midnight Amsterdam time: same play
|
||||
'Stan,2024-04-10 22:00:00,00:40:00,,Glass Onion: A Knives Out Mystery,,TV,,,NL',
|
||||
'Stan,2024-04-11 20:00:00,00:01:30,,Glass Onion: A Knives Out Mystery,TRAILER,TV,,,NL',
|
||||
'Stan,2024-04-12 20:00:00,00:02:00,,Roma,,TV,,,NL',
|
||||
'Juhee,2024-05-01 18:00:00,00:55:00,,Squid Game: Season 2: Bread and Lottery (Episode 1),,iPad,,,NL',
|
||||
'Kids,2024-05-01 18:00:00,00:55:00,,Peppa Pig: Season 1: Muddy Puddles (Episode 1),,iPad,,,NL'
|
||||
].join('\n');
|
||||
const r = parseNetflix(csv, {
|
||||
who: 'stan',
|
||||
profiles: parseWhoMap('Stan=stan,Juhee=juhee'),
|
||||
onlyProfiles: new Set(['stan', 'juhee'])
|
||||
});
|
||||
expect(r.skipped).toBe(3); // trailer, Kids profile, 2-minute Roma
|
||||
const glass = r.items.find((i) => i.title.startsWith('Glass'))!;
|
||||
expect(glass.type).toBe('movie');
|
||||
expect(glass.plays).toHaveLength(1);
|
||||
expect(glass.plays[0]).toMatchObject({
|
||||
watchedOn: '2024-04-10',
|
||||
durationMin: 120,
|
||||
who: 'stan'
|
||||
});
|
||||
const squid = r.items.find((i) => i.title === 'Squid Game')!;
|
||||
expect(squid.episodes).toEqual([{ season: 2, episode: 1, name: 'Bread and Lottery' }]);
|
||||
expect(squid.plays[0].who).toBe('juhee');
|
||||
});
|
||||
it('parses NetflixViewingHistory with guessed date order', () => {
|
||||
const csv =
|
||||
'Title,Date\n"The Crown: Season 1: Wolferton Splash","15/02/2024"\n"Roma","2/3/2024"\n';
|
||||
expect(guessDateOrder(['2/3/2024', '15/02/2024'])).toBe('dmy');
|
||||
const r = parseNetflix(csv, { who: 'both' });
|
||||
expect(r.items.find((i) => i.title === 'Roma')!.plays[0]).toMatchObject({
|
||||
watchedOn: '2024-03-02',
|
||||
who: 'both'
|
||||
});
|
||||
expect(r.items.find((i) => i.title === 'The Crown')!.type).toBe('show');
|
||||
});
|
||||
it('rejects other CSVs', () => {
|
||||
expect(() => parseNetflix('a,b\n1,2', { who: 'stan' })).toThrow(/not a Netflix/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('letterboxd', () => {
|
||||
it('merges watched, ratings, diary and watchlist', () => {
|
||||
const items = parseLetterboxd(
|
||||
{
|
||||
'export/watched.csv':
|
||||
'Date,Name,Year,Letterboxd URI\n2024-01-05,Past Lives,2023,https://boxd.it/a',
|
||||
'export/ratings.csv':
|
||||
'Date,Name,Year,Letterboxd URI,Rating\n2024-01-05,Past Lives,2023,https://boxd.it/a,4.5',
|
||||
'export/diary.csv':
|
||||
'Date,Name,Year,Letterboxd URI,Rating,Rewatch,Tags,Watched Date\n2024-01-05,Past Lives,2023,https://boxd.it/d1,4.5,,,2024-01-04\n2024-06-05,Past Lives,2023,https://boxd.it/d2,5,Yes,,2024-06-05',
|
||||
'export/watchlist.csv':
|
||||
'Date,Name,Year,Letterboxd URI\n2024-02-01,Perfect Days,2023,https://boxd.it/c'
|
||||
},
|
||||
{ who: 'stan' }
|
||||
);
|
||||
const past = items.find((i) => i.title === 'Past Lives')!;
|
||||
expect(past).toMatchObject({ type: 'movie', year: 2023, status: 'watched' });
|
||||
expect(past.plays.map((p) => p.watchedOn)).toEqual(['2024-01-04', '2024-06-05']);
|
||||
expect(new Set(past.plays.map((p) => p.sourceKey)).size).toBe(2);
|
||||
expect(items.find((i) => i.title === 'Perfect Days')!.status).toBe('watchlist');
|
||||
});
|
||||
});
|
||||
|
||||
describe('plex', () => {
|
||||
it('reads Tautulli history, skips partial plays and music', () => {
|
||||
const json = JSON.stringify({
|
||||
response: {
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
media_type: 'episode',
|
||||
title: 'Half Loop',
|
||||
grandparent_title: 'Severance',
|
||||
parent_media_index: 1,
|
||||
media_index: 2,
|
||||
started: 1717354800,
|
||||
play_duration: 3200,
|
||||
friendly_name: 'Juhee',
|
||||
percent_complete: 98,
|
||||
guids: ['tmdb://95396']
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
media_type: 'movie',
|
||||
title: 'Dune: Part Two',
|
||||
year: 2024,
|
||||
started: 1719100000,
|
||||
friendly_name: 'stan',
|
||||
percent_complete: 8
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
media_type: 'track',
|
||||
title: 'Song',
|
||||
started: 1719100000,
|
||||
friendly_name: 'stan',
|
||||
percent_complete: 100
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
const r = parsePlex(plexRows(json, 'json'), { who: 'stan', users: parseWhoMap('juhee=juhee') });
|
||||
expect(r.skipped).toBe(2);
|
||||
expect(r.items).toHaveLength(1);
|
||||
expect(r.items[0]).toMatchObject({ type: 'show', title: 'Severance', source: 'plex' });
|
||||
expect(r.items[0].plays[0]).toMatchObject({
|
||||
who: 'juhee',
|
||||
platform: 'plex',
|
||||
sourceKey: 'plex:1'
|
||||
});
|
||||
expect(tmdbFromGuids(['imdb://tt1', 'tmdb://95396'])).toBe(95396);
|
||||
});
|
||||
it('reads Tautulli CSV', () => {
|
||||
const csv =
|
||||
'id,media_type,title,year,date,duration,user,percent_complete\n7,movie,Past Lives,2023,1704400000,6000,stan,100\n';
|
||||
const r = parsePlex(plexRows(csv, 'csv'), { who: 'stan' });
|
||||
expect(r.items[0]).toMatchObject({ type: 'movie', title: 'Past Lives', year: 2023 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('manual and recs JSON', () => {
|
||||
it('validates and converts manual entries', () => {
|
||||
const parsed = manualFile.parse([
|
||||
{ type: 'tv', title: 'Shogun', year: 2024, status: 'watchlist' },
|
||||
{
|
||||
type: 'movie',
|
||||
title: 'Dune: Part Two',
|
||||
rating: 4.5,
|
||||
who: 'both',
|
||||
platform: 'cinema',
|
||||
watched_on: ['2024-03-02']
|
||||
}
|
||||
]);
|
||||
const [shogun, dune] = parsed.map(fromManual);
|
||||
expect(shogun).toMatchObject({ type: 'show', status: 'watchlist', mode: 'set' });
|
||||
expect(dune.plays).toHaveLength(1);
|
||||
expect(dune.plays[0]).toMatchObject({
|
||||
watchedOn: '2024-03-02',
|
||||
who: 'both',
|
||||
platform: 'cinema'
|
||||
});
|
||||
expect(manualFile.safeParse([{ type: 'movie', title: 'x', rating: 4.2 }]).success).toBe(false);
|
||||
});
|
||||
it('validates recommendations', () => {
|
||||
expect(
|
||||
recsFile.safeParse([
|
||||
{ tmdb_id: 1, type: 'movie', title: 'Inside Out 2', for: 'both', reason: 'cosy' }
|
||||
]).success
|
||||
).toBe(true);
|
||||
expect(
|
||||
recsFile.safeParse([{ tmdb_id: 1, type: 'movie', title: 'x', for: 'daisy' }]).success
|
||||
).toBe(false);
|
||||
});
|
||||
it('parses quoted CSV', () => {
|
||||
expect(parseCsv('\ufeffa,b\r\n"x, ""y""",2\r\n')).toEqual([{ a: 'x, "y"', b: '2' }]);
|
||||
});
|
||||
});
|
||||
661
src/lib/server/movies/parse.ts
Normal file
661
src/lib/server/movies/parse.ts
Normal file
@@ -0,0 +1,661 @@
|
||||
// Movies & TV tracker: source parsers (Netflix, Letterboxd, Plex/Tautulli, manual JSON) that turn
|
||||
// an export into ImportItems. Pure functions (no DB, no SvelteKit) so the import scripts and the
|
||||
// unit tests share them. See scripts/movies-import.ts for the formats each one accepts.
|
||||
import * as z from 'zod';
|
||||
import {
|
||||
MOVIE_PLATFORMS,
|
||||
MOVIE_STATUSES,
|
||||
MOVIE_WHO,
|
||||
type MoviePlatform,
|
||||
type MovieSource,
|
||||
type MovieStatus,
|
||||
type MovieType,
|
||||
type MovieWho
|
||||
} from '../db/schema/movies';
|
||||
|
||||
export type ImportPlay = {
|
||||
watchedOn: string;
|
||||
watchedAt?: string | null;
|
||||
durationMin?: number | null;
|
||||
who: MovieWho;
|
||||
platform: MoviePlatform | null;
|
||||
sourceKey: string;
|
||||
episode?: ImportEpisode | null;
|
||||
};
|
||||
export type ImportEpisode = { season: number | null; episode: number | null; name: string | null };
|
||||
export type ImportItem = {
|
||||
type: MovieType;
|
||||
title: string;
|
||||
year?: number | null;
|
||||
tmdbId?: number | null;
|
||||
source: MovieSource;
|
||||
/** fill = only set empty fields (exports); set = given fields win (manual JSON, the site). */
|
||||
mode: 'fill' | 'set';
|
||||
status?: MovieStatus | null;
|
||||
rating?: number | null;
|
||||
rewatchCount?: number | null;
|
||||
firstWatched?: string | null;
|
||||
lastWatched?: string | null;
|
||||
who?: MovieWho | null;
|
||||
platform?: MoviePlatform | null;
|
||||
notes?: string | null;
|
||||
episodes?: ImportEpisode[];
|
||||
plays: ImportPlay[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
/** Lower-case, accents and punctuation stripped, `&` → and, single spaces. */
|
||||
export function normTitle(s: string): string {
|
||||
return s
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/&/g, ' and ')
|
||||
.replace(/['’`]/g, '')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
export const matchKey = (type: MovieType, title: string, year?: number | null) =>
|
||||
`${type}:${normTitle(title)}:${year ?? ''}`;
|
||||
export const epKey = (e: ImportEpisode) =>
|
||||
e.episode != null
|
||||
? `s${e.season ?? 0}e${e.episode}`
|
||||
: `s${e.season ?? 0}:${normTitle(e.name ?? '')}`;
|
||||
|
||||
/** Minimal RFC 4180 CSV parser (quotes, doubled quotes, CRLF, BOM). Returns header-keyed rows. */
|
||||
export function parseCsv(text: string): Record<string, string>[] {
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let field = '';
|
||||
let quoted = false;
|
||||
const s = text.replace(/^\uFEFF/, '');
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (quoted) {
|
||||
if (ch === '"') {
|
||||
if (s[i + 1] === '"') {
|
||||
field += '"';
|
||||
i++;
|
||||
} else quoted = false;
|
||||
} else field += ch;
|
||||
} else if (ch === '"') quoted = true;
|
||||
else if (ch === ',') {
|
||||
row.push(field);
|
||||
field = '';
|
||||
} else if (ch === '\n' || ch === '\r') {
|
||||
if (ch === '\r' && s[i + 1] === '\n') i++;
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
row = [];
|
||||
field = '';
|
||||
} else field += ch;
|
||||
}
|
||||
if (field !== '' || row.length) {
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
}
|
||||
const nonEmpty = rows.filter((r) => r.some((c) => c.trim() !== ''));
|
||||
if (!nonEmpty.length) return [];
|
||||
const header = nonEmpty[0].map((h) => h.trim());
|
||||
return nonEmpty
|
||||
.slice(1)
|
||||
.map((r) => Object.fromEntries(header.map((h, i) => [h, (r[i] ?? '').trim()])));
|
||||
}
|
||||
|
||||
/** Calendar date in Europe/Amsterdam for an instant. */
|
||||
export const amsDate = (d: Date) => d.toLocaleDateString('sv-SE', { timeZone: 'Europe/Amsterdam' });
|
||||
const isoDate = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
function ymd(y: number, m: number, d: number): string | null {
|
||||
if (y < 100) y += y < 70 ? 2000 : 1900;
|
||||
if (m < 1 || m > 12 || d < 1 || d > 31) return null;
|
||||
return `${y}-${pad(m)}-${pad(d)}`;
|
||||
}
|
||||
|
||||
/** "Name=who,Other=juhee" → map of normalised profile/user name to who. */
|
||||
export function parseWhoMap(spec: string | undefined): Map<string, MovieWho> {
|
||||
const out = new Map<string, MovieWho>();
|
||||
for (const part of (spec ?? '').split(',')) {
|
||||
const [k, v] = part.split('=').map((x) => x?.trim());
|
||||
if (!k || !v) continue;
|
||||
if (!(MOVIE_WHO as readonly string[]).includes(v)) throw new Error(`bad who "${v}" in map`);
|
||||
out.set(normTitle(k), v as MovieWho);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Netflix
|
||||
|
||||
// Season-like segment in Netflix titles ("Show: Season 2: Episode"), in the languages a Dutch
|
||||
// account sees. A segment that matches starts the episode part.
|
||||
const SEASON_SEG =
|
||||
/^(season|seizoen|staffel|saison|temporada|stagione|series|serie|part|deel|teil|volume|vol\.?|book|boek|chapter|hoofdstuk|limited series|miniserie|miniseries|collection|collectie)\b\s*(\d+|[ivx]+|one|two|three|four|five|six|seven|eight|nine|ten)?/i;
|
||||
const WORD_NUM: Record<string, number> = {
|
||||
one: 1,
|
||||
two: 2,
|
||||
three: 3,
|
||||
four: 4,
|
||||
five: 5,
|
||||
six: 6,
|
||||
seven: 7,
|
||||
eight: 8,
|
||||
nine: 9,
|
||||
ten: 10
|
||||
};
|
||||
function seasonNumber(seg: string): number {
|
||||
const m = SEASON_SEG.exec(seg.trim());
|
||||
const n = m?.[2]?.toLowerCase();
|
||||
if (!n) return 1;
|
||||
if (/^\d+$/.test(n)) return Number(n);
|
||||
if (WORD_NUM[n]) return WORD_NUM[n];
|
||||
const roman: Record<string, number> = { i: 1, v: 5, x: 10 };
|
||||
let total = 0;
|
||||
for (let i = 0; i < n.length; i++) {
|
||||
const a = roman[n[i]] ?? 0;
|
||||
const b = roman[n[i + 1]] ?? 0;
|
||||
total += a < b ? -a : a;
|
||||
}
|
||||
return total || 1;
|
||||
}
|
||||
|
||||
export type NetflixTitle =
|
||||
| { type: 'movie'; title: string }
|
||||
| { type: 'show'; title: string; season: number; episode: number | null; name: string | null };
|
||||
|
||||
// "Chapter One: … (Episode 1)": ViewingActivity.csv appends the episode number in most languages.
|
||||
const EPISODE_SUFFIX =
|
||||
/\s*\((?:episode|aflevering|folge|épisode|episodio|episódio|odcinek|avsnitt|afsnit|jakso|bölüm|에피소드|エピソード)\s*(\d{1,4})\)\s*$/iu;
|
||||
function episodeName(name: string | null): { episode: number | null; name: string | null } {
|
||||
if (!name) return { episode: null, name };
|
||||
const m = EPISODE_SUFFIX.exec(name);
|
||||
if (!m) return { episode: null, name };
|
||||
return { episode: Number(m[1]), name: name.slice(0, m.index).trim() || null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a Netflix history title. "Show: Season 2: Ep" / "Show: Limited Series: Ep" → episode.
|
||||
* Without a season segment, a title whose first segment appears with 3+ different remainders in
|
||||
* the same file (`showPrefixes`) is a show too; everything else is a movie
|
||||
* ("Mission: Impossible: Dead Reckoning" stays a movie unless Netflix lists 3+ of them).
|
||||
*/
|
||||
export function splitNetflixTitle(raw: string, showPrefixes: Set<string>): NetflixTitle {
|
||||
const segs = raw.split(': ').map((s) => s.trim());
|
||||
const si = segs.findIndex((s, i) => i > 0 && SEASON_SEG.test(s));
|
||||
if (si > 0) {
|
||||
const ep = episodeName(segs.slice(si + 1).join(': ') || null);
|
||||
return {
|
||||
type: 'show',
|
||||
title: segs.slice(0, si).join(': '),
|
||||
season: seasonNumber(segs[si]),
|
||||
...ep
|
||||
};
|
||||
}
|
||||
if (segs.length >= 2 && showPrefixes.has(normTitle(segs[0])))
|
||||
return { type: 'show', title: segs[0], season: 1, ...episodeName(segs.slice(1).join(': ')) };
|
||||
return { type: 'movie', title: raw.trim() };
|
||||
}
|
||||
|
||||
function netflixShowPrefixes(titles: string[]): Set<string> {
|
||||
const rest = new Map<string, Set<string>>();
|
||||
for (const t of new Set(titles)) {
|
||||
const segs = t.split(': ');
|
||||
if (segs.length < 2) continue;
|
||||
const k = normTitle(segs[0]);
|
||||
(rest.get(k) ?? rest.set(k, new Set()).get(k)!).add(segs.slice(1).join(': '));
|
||||
}
|
||||
return new Set([...rest].filter(([, s]) => s.size >= 3).map(([k]) => k));
|
||||
}
|
||||
|
||||
/** Date in NetflixViewingHistory.csv: ISO, D-M-YYYY (Dutch site), or D/M/Y vs M/D/Y (`order`). */
|
||||
function netflixDate(s: string, order: 'dmy' | 'mdy'): string | null {
|
||||
const t = s.trim();
|
||||
if (isoDate.test(t)) return t;
|
||||
let m = /^(\d{1,2})-(\d{1,2})-(\d{2,4})$/.exec(t);
|
||||
if (m) return ymd(+m[3], +m[2], +m[1]);
|
||||
m = /^(\d{1,2})[/.](\d{1,2})[/.](\d{2,4})$/.exec(t);
|
||||
if (m) return order === 'dmy' ? ymd(+m[3], +m[2], +m[1]) : ymd(+m[3], +m[1], +m[2]);
|
||||
return null;
|
||||
}
|
||||
/** Guess D/M vs M/D for slash dates from the whole file; null when nothing decides it. */
|
||||
export function guessDateOrder(dates: string[]): 'dmy' | 'mdy' | null {
|
||||
for (const d of dates) {
|
||||
const m = /^(\d{1,2})[/.](\d{1,2})[/.]\d{2,4}$/.exec(d.trim());
|
||||
if (!m) continue;
|
||||
if (+m[1] > 12) return 'dmy';
|
||||
if (+m[2] > 12) return 'mdy';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const hms = (s: string) => {
|
||||
const m = /^(\d+):(\d{2}):(\d{2})$/.exec(s.trim());
|
||||
return m ? +m[1] * 60 + +m[2] + +m[3] / 60 : null;
|
||||
};
|
||||
|
||||
export type NetflixOptions = {
|
||||
/** Who watched, for NetflixViewingHistory.csv (no profile column) and unmapped profiles. */
|
||||
who: MovieWho;
|
||||
/** ViewingActivity.csv profile name → who; profiles not in the map use `who`. */
|
||||
profiles?: Map<string, MovieWho>;
|
||||
/** Only these profiles (normalised names); empty = all. */
|
||||
onlyProfiles?: Set<string>;
|
||||
dateOrder?: 'dmy' | 'mdy';
|
||||
/** ViewingActivity sessions shorter than this (summed per title and day) are skipped. */
|
||||
minMinutes?: number;
|
||||
};
|
||||
|
||||
/** Netflix ViewingActivity.csv (Get my info) or NetflixViewingHistory.csv (Viewing activity page). */
|
||||
export function parseNetflix(
|
||||
text: string,
|
||||
o: NetflixOptions
|
||||
): { items: ImportItem[]; skipped: number } {
|
||||
const rows = parseCsv(text);
|
||||
if (!rows.length) return { items: [], skipped: 0 };
|
||||
const cols = Object.keys(rows[0]);
|
||||
const activity = cols.includes('Start Time') && cols.includes('Title');
|
||||
if (!activity && !(cols.includes('Title') && cols.includes('Date')))
|
||||
throw new Error(`not a Netflix history CSV (columns: ${cols.join(', ')})`);
|
||||
const prefixes = netflixShowPrefixes(rows.map((r) => r.Title));
|
||||
const minMinutes = o.minMinutes ?? 5;
|
||||
let skipped = 0;
|
||||
// (who, title, date) → aggregated play
|
||||
const plays = new Map<
|
||||
string,
|
||||
{ raw: string; date: string; at: string | null; min: number | null; who: MovieWho }
|
||||
>();
|
||||
if (activity) {
|
||||
// One play per viewing session: sessions of the same title by the same person that start
|
||||
// within SESSION_GAP of the previous one's end are one play (a film paused past midnight),
|
||||
// dated by the session's first Amsterdam day; same-day sessions are one play too.
|
||||
const SESSION_GAP = 3 * 3600_000;
|
||||
const open = new Map<string, { key: string; end: number }>();
|
||||
const sorted = rows
|
||||
.map((r) => ({ r, t: new Date((r['Start Time'] ?? '').replace(' ', 'T') + 'Z').getTime() }))
|
||||
.sort((a, b) => (Number.isNaN(a.t) ? 0 : a.t) - (Number.isNaN(b.t) ? 0 : b.t));
|
||||
for (const { r } of sorted) {
|
||||
if (r['Supplemental Video Type']) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const profile = normTitle(r['Profile Name'] ?? '');
|
||||
if (o.onlyProfiles?.size && !o.onlyProfiles.has(profile)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const who = o.profiles?.get(profile) ?? o.who;
|
||||
const at = new Date(r['Start Time'].replace(' ', 'T') + 'Z');
|
||||
if (Number.isNaN(at.getTime()) || !r.Title) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const min = hms(r.Duration ?? '') ?? 0;
|
||||
const session = `${who}|${r.Title}`;
|
||||
const prev = open.get(session);
|
||||
const k =
|
||||
prev && at.getTime() <= prev.end + SESSION_GAP ? prev.key : `${session}|${amsDate(at)}`;
|
||||
const date = k.slice(k.lastIndexOf('|') + 1);
|
||||
open.set(session, { key: k, end: at.getTime() + min * 60_000 });
|
||||
const p = plays.get(k);
|
||||
if (p) {
|
||||
p.min = (p.min ?? 0) + min;
|
||||
if (at.toISOString() < (p.at ?? '')) p.at = at.toISOString();
|
||||
} else plays.set(k, { raw: r.Title, date, at: at.toISOString(), min, who });
|
||||
}
|
||||
for (const [k, p] of plays)
|
||||
if ((p.min ?? 0) < minMinutes) {
|
||||
plays.delete(k);
|
||||
skipped++;
|
||||
}
|
||||
} else {
|
||||
const order = o.dateOrder ?? guessDateOrder(rows.map((r) => r.Date)) ?? 'dmy';
|
||||
for (const r of rows) {
|
||||
const date = netflixDate(r.Date, order);
|
||||
if (!date || !r.Title) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
plays.set(`${o.who}|${r.Title}|${date}`, {
|
||||
raw: r.Title,
|
||||
date,
|
||||
at: null,
|
||||
min: null,
|
||||
who: o.who
|
||||
});
|
||||
}
|
||||
}
|
||||
const items = new Map<string, ImportItem>();
|
||||
for (const p of plays.values()) {
|
||||
const t = splitNetflixTitle(p.raw, prefixes);
|
||||
const key = matchKey(t.type, t.title);
|
||||
let item = items.get(key);
|
||||
if (!item) {
|
||||
item = {
|
||||
type: t.type,
|
||||
title: t.title,
|
||||
source: 'netflix',
|
||||
mode: 'fill',
|
||||
platform: 'netflix',
|
||||
plays: [],
|
||||
episodes: []
|
||||
};
|
||||
items.set(key, item);
|
||||
}
|
||||
const episode =
|
||||
t.type === 'show' ? { season: t.season, episode: t.episode, name: t.name } : null;
|
||||
if (episode && !item.episodes!.some((e) => epKey(e) === epKey(episode)))
|
||||
item.episodes!.push(episode);
|
||||
item.plays.push({
|
||||
watchedOn: p.date,
|
||||
watchedAt: p.at,
|
||||
durationMin: p.min != null ? Math.round(p.min) : null,
|
||||
who: p.who,
|
||||
platform: 'netflix',
|
||||
sourceKey: `netflix:${p.who}:${normTitle(p.raw)}:${p.date}`,
|
||||
episode
|
||||
});
|
||||
}
|
||||
return { items: [...items.values()], skipped };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Letterboxd
|
||||
|
||||
export type LetterboxdOptions = { who: MovieWho; platform?: MoviePlatform | null };
|
||||
|
||||
/**
|
||||
* Letterboxd data export (Settings → Data → Export your data): the zip's CSVs, keyed by file name.
|
||||
* Uses diary.csv (one play per diary entry, on its Watched Date), ratings.csv (latest rating),
|
||||
* watched.csv (status watched, no play: its date is when it was logged) and watchlist.csv.
|
||||
* Letterboxd is films only, so every item is a movie.
|
||||
*/
|
||||
export function parseLetterboxd(files: Record<string, string>, o: LetterboxdOptions): ImportItem[] {
|
||||
const get = (name: string) => {
|
||||
const k = Object.keys(files).find((f) => f.replace(/^.*\//, '').toLowerCase() === name);
|
||||
return k ? parseCsv(files[k]) : [];
|
||||
};
|
||||
const items = new Map<string, ImportItem>();
|
||||
const item = (r: Record<string, string>) => {
|
||||
const year = /^\d{4}$/.test(r.Year ?? '') ? Number(r.Year) : null;
|
||||
const key = matchKey('movie', r.Name, year);
|
||||
let it = items.get(key);
|
||||
if (!it) {
|
||||
it = {
|
||||
type: 'movie',
|
||||
title: r.Name,
|
||||
year,
|
||||
source: 'letterboxd',
|
||||
mode: 'fill',
|
||||
plays: [],
|
||||
platform: o.platform ?? null
|
||||
};
|
||||
items.set(key, it);
|
||||
}
|
||||
return it;
|
||||
};
|
||||
const rating = (s: string | undefined) => {
|
||||
const n = Number(s);
|
||||
return s && n >= 0.5 && n <= 5 ? Math.round(n * 2) / 2 : null;
|
||||
};
|
||||
for (const r of get('watchlist.csv')) if (r.Name) item(r).status ??= 'watchlist';
|
||||
for (const r of get('watched.csv')) if (r.Name) item(r).status = 'watched';
|
||||
for (const r of get('ratings.csv')) {
|
||||
if (!r.Name) continue;
|
||||
const it = item(r);
|
||||
it.status = 'watched';
|
||||
it.rating = rating(r.Rating) ?? it.rating;
|
||||
}
|
||||
const diary = get('diary.csv').sort((a, b) =>
|
||||
(a['Watched Date'] || a.Date).localeCompare(b['Watched Date'] || b.Date)
|
||||
);
|
||||
diary.forEach((r, i) => {
|
||||
if (!r.Name) return;
|
||||
const date = r['Watched Date'] || r.Date;
|
||||
if (!isoDate.test(date)) return;
|
||||
const it = item(r);
|
||||
it.status = 'watched';
|
||||
it.rating = rating(r.Rating) ?? it.rating;
|
||||
it.plays.push({
|
||||
watchedOn: date,
|
||||
who: o.who,
|
||||
platform: o.platform ?? null,
|
||||
sourceKey: `letterboxd:${r['Letterboxd URI'] || `${normTitle(r.Name)}:${r.Year}:${date}:${i}`}`
|
||||
});
|
||||
});
|
||||
return [...items.values()];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Plex / Tautulli
|
||||
|
||||
export type PlexOptions = {
|
||||
who: MovieWho;
|
||||
users?: Map<string, MovieWho>;
|
||||
onlyUsers?: Set<string>;
|
||||
/** Skip plays below this percent watched when the export says (Tautulli percent_complete). */
|
||||
minPercent?: number;
|
||||
};
|
||||
|
||||
const pick = (r: Record<string, unknown>, ...keys: string[]) => {
|
||||
for (const k of keys) {
|
||||
const v = r[k];
|
||||
if (v !== undefined && v !== null && v !== '') return v;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const num = (v: unknown) => (v === undefined || v === '' || v === null ? null : Number(v));
|
||||
|
||||
/** TMDB id from a Plex/Tautulli guid string or Guid array ("tmdb://603", "themoviedb://603"). */
|
||||
export function tmdbFromGuids(v: unknown): number | null {
|
||||
const list = Array.isArray(v)
|
||||
? v.map((g) => (typeof g === 'object' && g ? (g as { id?: string }).id : g))
|
||||
: [v];
|
||||
for (const g of list) {
|
||||
const m = /(?:tmdb|themoviedb):\/\/(\d+)/.exec(String(g ?? ''));
|
||||
if (m) return Number(m[1]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function plexDate(v: unknown): Date | null {
|
||||
if (v === undefined || v === null || v === '') return null;
|
||||
const s = String(v).trim();
|
||||
if (/^\d{9,11}$/.test(s)) return new Date(Number(s) * 1000);
|
||||
if (/^\d{12,14}$/.test(s)) return new Date(Number(s));
|
||||
const d = new Date(s.includes('T') || s.includes(' ') ? s.replace(' ', 'T') : `${s}T12:00:00Z`);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
/** Rows from a Tautulli history CSV/JSON (get_history), or Plex's /status/sessions/history/all JSON. */
|
||||
export function plexRows(text: string, kind: 'json' | 'csv'): Record<string, unknown>[] {
|
||||
if (kind === 'csv') return parseCsv(text);
|
||||
const j = JSON.parse(text);
|
||||
const arr = Array.isArray(j)
|
||||
? j
|
||||
: (j?.response?.data?.data ??
|
||||
j?.response?.data ??
|
||||
j?.MediaContainer?.Metadata ??
|
||||
j?.data ??
|
||||
j?.history);
|
||||
if (!Array.isArray(arr)) throw new Error('no history array found in the Plex/Tautulli JSON');
|
||||
return arr;
|
||||
}
|
||||
|
||||
export function parsePlex(
|
||||
rows: Record<string, unknown>[],
|
||||
o: PlexOptions
|
||||
): { items: ImportItem[]; skipped: number } {
|
||||
const items = new Map<string, ImportItem>();
|
||||
let skipped = 0;
|
||||
rows.forEach((r, i) => {
|
||||
const mediaType = String(pick(r, 'media_type', 'type', 'Media Type') ?? '').toLowerCase();
|
||||
const user = normTitle(
|
||||
String(pick(r, 'friendly_name', 'user', 'username', 'User', 'accountName', 'account') ?? '')
|
||||
);
|
||||
if (o.onlyUsers?.size && !o.onlyUsers.has(user)) return void skipped++;
|
||||
const pct = num(pick(r, 'percent_complete', 'Percent Complete'));
|
||||
if (pct != null && pct < (o.minPercent ?? 50)) return void skipped++;
|
||||
const at = plexDate(
|
||||
pick(r, 'started', 'date', 'viewedAt', 'viewed_at', 'Date', 'Started', 'stopped')
|
||||
);
|
||||
if (!at) return void skipped++;
|
||||
const who = o.users?.get(user) ?? o.who;
|
||||
const isEp = mediaType === 'episode';
|
||||
if (!isEp && mediaType !== 'movie') return void skipped++; // tracks, photos, clips
|
||||
const show = String(pick(r, 'grandparent_title', 'grandparentTitle', 'Show') ?? '');
|
||||
const title = isEp ? show : String(pick(r, 'title', 'Title', 'full_title') ?? '');
|
||||
if (!title) return void skipped++;
|
||||
const year = num(pick(r, isEp ? 'grandparent_year' : 'year', 'year', 'Year'));
|
||||
const type: MovieType = isEp ? 'show' : 'movie';
|
||||
const tmdb = isEp
|
||||
? tmdbFromGuids(pick(r, 'grandparent_guids', 'grandparentGuid'))
|
||||
: tmdbFromGuids(pick(r, 'guids', 'Guid', 'guid'));
|
||||
const key = tmdb
|
||||
? `${type}:tmdb:${tmdb}`
|
||||
: matchKey(type, title, year && year > 1800 ? year : null);
|
||||
let it = items.get(key);
|
||||
if (!it) {
|
||||
it = {
|
||||
type,
|
||||
title,
|
||||
year: year && year > 1800 ? year : null,
|
||||
tmdbId: tmdb,
|
||||
source: 'plex',
|
||||
mode: 'fill',
|
||||
platform: 'plex',
|
||||
plays: [],
|
||||
episodes: []
|
||||
};
|
||||
items.set(key, it);
|
||||
}
|
||||
const episode = isEp
|
||||
? {
|
||||
season: num(pick(r, 'parent_media_index', 'parentIndex', 'Season')),
|
||||
episode: num(pick(r, 'media_index', 'index', 'Episode')),
|
||||
name: (pick(r, 'title', 'Title') as string) ?? null
|
||||
}
|
||||
: null;
|
||||
if (episode && !it.episodes!.some((e) => epKey(e) === epKey(episode)))
|
||||
it.episodes!.push(episode);
|
||||
const secs = num(pick(r, 'play_duration', 'duration', 'Duration'));
|
||||
const id = pick(r, 'id', 'row_id', 'reference_id', 'historyKey');
|
||||
const rk = pick(r, 'rating_key', 'ratingKey');
|
||||
it.plays.push({
|
||||
watchedOn: amsDate(at),
|
||||
watchedAt: at.toISOString(),
|
||||
durationMin: secs != null && Number.isFinite(secs) ? Math.round(secs / 60) : null,
|
||||
who,
|
||||
platform: 'plex',
|
||||
sourceKey:
|
||||
id != null
|
||||
? `plex:${id}`
|
||||
: `plex:${rk ?? normTitle(title)}:${episode ? epKey(episode) : ''}:${user}:${at.getTime()}:${i}`,
|
||||
episode
|
||||
});
|
||||
});
|
||||
return { items: [...items.values()], skipped };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- manual JSON (Ju / Daisy)
|
||||
|
||||
const date = z.string().regex(isoDate, 'date must be YYYY-MM-DD');
|
||||
const typeIn = z
|
||||
.enum(['movie', 'show', 'tv'])
|
||||
.transform((t) => (t === 'tv' ? 'show' : t) as MovieType);
|
||||
const ratingIn = z
|
||||
.number()
|
||||
.min(0.5)
|
||||
.max(5)
|
||||
.refine((n) => Number.isInteger(n * 2), 'rating must be in steps of 0.5');
|
||||
|
||||
export const manualItem = z.object({
|
||||
type: typeIn,
|
||||
title: z.string().trim().min(1).max(300),
|
||||
year: z.number().int().min(1870).max(2100).nullish(),
|
||||
tmdb_id: z.number().int().positive().nullish(),
|
||||
status: z.enum(MOVIE_STATUSES).nullish(),
|
||||
rating: ratingIn.nullish(),
|
||||
rewatch_count: z.number().int().min(0).max(1000).nullish(),
|
||||
first_watched: date.nullish(),
|
||||
last_watched: date.nullish(),
|
||||
who: z.enum(MOVIE_WHO).nullish(),
|
||||
platform: z.enum(MOVIE_PLATFORMS).nullish(),
|
||||
notes: z.string().trim().max(2000).nullish(),
|
||||
source: z.enum(['manual', 'ju']).default('manual'),
|
||||
/** Days a movie was watched (one play each). For shows use episodes[].watched_on. */
|
||||
watched_on: z.array(date).max(500).default([]),
|
||||
episodes: z
|
||||
.array(
|
||||
z.object({
|
||||
season: z.number().int().min(0).max(200),
|
||||
episode: z.number().int().min(0).max(5000).nullish(),
|
||||
name: z.string().trim().max(300).nullish(),
|
||||
watched_on: date.nullish()
|
||||
})
|
||||
)
|
||||
.max(5000)
|
||||
.default([])
|
||||
});
|
||||
export const manualFile = z.array(manualItem).max(20000);
|
||||
export type ManualItem = z.infer<typeof manualItem>;
|
||||
|
||||
export function fromManual(m: ManualItem): ImportItem {
|
||||
const who = m.who ?? 'stan';
|
||||
const id = m.tmdb_id
|
||||
? `tmdb${m.tmdb_id}`
|
||||
: normTitle(m.title).replace(/ /g, '-') + (m.year ? `-${m.year}` : '');
|
||||
const plays: ImportPlay[] = m.watched_on.map((d) => ({
|
||||
watchedOn: d,
|
||||
who,
|
||||
platform: m.platform ?? null,
|
||||
sourceKey: `${m.source}:${m.type}:${id}:${d}`
|
||||
}));
|
||||
const episodes: ImportEpisode[] = m.episodes.map((e) => ({
|
||||
season: e.season,
|
||||
episode: e.episode ?? null,
|
||||
name: e.name ?? null
|
||||
}));
|
||||
m.episodes.forEach((e, i) => {
|
||||
if (e.watched_on)
|
||||
plays.push({
|
||||
watchedOn: e.watched_on,
|
||||
who,
|
||||
platform: m.platform ?? null,
|
||||
sourceKey: `${m.source}:${m.type}:${id}:${epKey(episodes[i])}:${e.watched_on}`,
|
||||
episode: episodes[i]
|
||||
});
|
||||
});
|
||||
return {
|
||||
type: m.type,
|
||||
title: m.title,
|
||||
year: m.year ?? null,
|
||||
tmdbId: m.tmdb_id ?? null,
|
||||
source: m.source,
|
||||
mode: 'set',
|
||||
status: m.status ?? null,
|
||||
rating: m.rating ?? null,
|
||||
rewatchCount: m.rewatch_count ?? null,
|
||||
firstWatched: m.first_watched ?? null,
|
||||
lastWatched: m.last_watched ?? null,
|
||||
who: m.who ?? null,
|
||||
platform: m.platform ?? null,
|
||||
notes: m.notes ?? null,
|
||||
episodes,
|
||||
plays
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Ju recommends
|
||||
|
||||
export const recItem = z.object({
|
||||
tmdb_id: z.number().int().positive(),
|
||||
type: typeIn,
|
||||
title: z.string().trim().min(1).max(300),
|
||||
for: z.enum(MOVIE_WHO),
|
||||
reason: z.string().trim().max(600).nullish(),
|
||||
added_at: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((s) => !Number.isNaN(Date.parse(s)), 'added_at must be an ISO date or date-time')
|
||||
.nullish()
|
||||
});
|
||||
export const recsFile = z.array(recItem).max(5000);
|
||||
export type RecItem = z.infer<typeof recItem>;
|
||||
264
src/lib/server/movies/site.ts
Normal file
264
src/lib/server/movies/site.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
// Movies & TV tracker (/trackers/movies): page reads and the owner's edit actions.
|
||||
// Imports go through scripts/movies-import.ts; everything here reuses the same store functions.
|
||||
import { client as sql } from '#lib/server/db/index.js';
|
||||
import * as z from 'zod';
|
||||
import { MOVIE_PLATFORMS, MOVIE_STATUSES, MOVIE_WHO } from '#lib/server/db/schema/movies.js';
|
||||
import { importItems } from './store';
|
||||
import { normTitle } from './parse';
|
||||
import { Tmdb, runTmdb } from './tmdb';
|
||||
|
||||
export type Visibility = 'public' | 'private';
|
||||
|
||||
let cached: { at: number; value: Visibility } | null = null;
|
||||
/** The owner's visibility switch (public by default; private hides the tracker from everyone else). */
|
||||
export async function getVisibility(fresh = false): Promise<Visibility> {
|
||||
if (!fresh && cached && Date.now() - cached.at < 30_000) return cached.value;
|
||||
let value: Visibility = 'public';
|
||||
try {
|
||||
const [r] = await sql`select value from movies.settings where key = 'visibility'`;
|
||||
value = r?.value === 'private' ? 'private' : 'public';
|
||||
} catch {
|
||||
// Schema not migrated yet (fresh dev DB): treat as public.
|
||||
}
|
||||
cached = { at: Date.now(), value };
|
||||
return value;
|
||||
}
|
||||
/** Tracker names hidden from the public navigation (root layout → visibleTrackers). */
|
||||
export async function loadHiddenTrackers(): Promise<string[]> {
|
||||
return (await getVisibility()) === 'private' ? ['movies'] : [];
|
||||
}
|
||||
|
||||
export type Title = {
|
||||
id: number;
|
||||
type: 'movie' | 'show';
|
||||
tmdbId: number | null;
|
||||
title: string;
|
||||
year: number | null;
|
||||
status: string;
|
||||
rating: number | null;
|
||||
rewatchCount: number;
|
||||
firstWatched: string | null;
|
||||
lastWatched: string | null;
|
||||
watchedBy: string | null;
|
||||
platform: string | null;
|
||||
source: string;
|
||||
sources: string[];
|
||||
notes: string | null;
|
||||
posterPath: string | null;
|
||||
overview: string | null;
|
||||
genres: string[];
|
||||
cast: string[];
|
||||
runtimeMin: number | null;
|
||||
totalSeasons: number | null;
|
||||
totalEpisodes: number | null;
|
||||
plays: number;
|
||||
episodesWatched: number;
|
||||
seasons: { season: number; watched: number }[];
|
||||
matchStatus: string;
|
||||
};
|
||||
|
||||
export async function loadTitles(): Promise<Title[]> {
|
||||
const rows = await sql`
|
||||
select t.id, t.type, t.tmdb_id, t.title, t.year, t.status, t.rating, t.rewatch_count,
|
||||
t.first_watched::text, t.last_watched::text, t.watched_by, t.platform, t.source, t.sources, t.notes,
|
||||
t.poster_path, t.overview, t.genres, t."cast", t.runtime_min, t.total_seasons, t.total_episodes, t.match_status,
|
||||
coalesce(p.plays, 0)::int as plays, coalesce(e.watched, 0)::int as episodes_watched, coalesce(e.seasons, '[]') as seasons
|
||||
from movies.titles t
|
||||
left join (select title_id, count(*) as plays from movies.plays group by title_id) p on p.title_id = t.id
|
||||
left join (
|
||||
select title_id, sum(n) as watched, json_agg(json_build_object('season', season, 'watched', n) order by season) as seasons
|
||||
from (select e.title_id, coalesce(e.season, 0) as season, count(*) as n from movies.episodes e
|
||||
where exists (select 1 from movies.plays pl where pl.episode_id = e.id) group by 1, 2) s
|
||||
group by title_id
|
||||
) e on e.title_id = t.id
|
||||
order by coalesce(t.last_watched, t.created_at::date) desc nulls last, t.id desc`;
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
tmdbId: r.tmdb_id,
|
||||
title: r.title,
|
||||
year: r.year,
|
||||
status: r.status,
|
||||
rating: r.rating,
|
||||
rewatchCount: r.rewatch_count,
|
||||
firstWatched: r.first_watched,
|
||||
lastWatched: r.last_watched,
|
||||
watchedBy: r.watched_by,
|
||||
platform: r.platform,
|
||||
source: r.source,
|
||||
sources: r.sources,
|
||||
notes: r.notes,
|
||||
posterPath: r.poster_path,
|
||||
overview: r.overview,
|
||||
genres: r.genres,
|
||||
cast: r.cast,
|
||||
runtimeMin: r.runtime_min,
|
||||
totalSeasons: r.total_seasons,
|
||||
totalEpisodes: r.total_episodes,
|
||||
plays: r.plays,
|
||||
episodesWatched: r.episodes_watched,
|
||||
seasons: r.seasons,
|
||||
matchStatus: r.match_status
|
||||
}));
|
||||
}
|
||||
|
||||
export type Stats = {
|
||||
months: { month: string; movies: number; episodes: number; minutes: number }[];
|
||||
years: { year: string; movies: number; episodes: number; minutes: number }[];
|
||||
genres: { genre: string; titles: number }[];
|
||||
who: { who: string; plays: number; minutes: number }[];
|
||||
platforms: { platform: string; plays: number; minutes: number }[];
|
||||
totals: { plays: number; minutes: number; movies: number; shows: number; episodes: number };
|
||||
};
|
||||
|
||||
export async function loadStats(): Promise<Stats> {
|
||||
// Minutes per play: what the source measured, else the episode/movie runtime from TMDB.
|
||||
const base = sql`
|
||||
select p.watched_on, p.who, coalesce(p.platform, 'other') as platform, t.type,
|
||||
coalesce(p.duration_min, e.runtime_min, t.runtime_min, 0) as minutes, p.title_id, p.episode_id
|
||||
from movies.plays p join movies.titles t on t.id = p.title_id left join movies.episodes e on e.id = p.episode_id`;
|
||||
const [months, years, genres, who, platforms, [totals]] = await Promise.all([
|
||||
sql`select to_char(watched_on, 'YYYY-MM') as month, count(*) filter (where type = 'movie')::int as movies,
|
||||
count(*) filter (where type = 'show')::int as episodes, sum(minutes)::int as minutes
|
||||
from (${base}) b group by 1 order by 1`,
|
||||
sql`select to_char(watched_on, 'YYYY') as year, count(*) filter (where type = 'movie')::int as movies,
|
||||
count(*) filter (where type = 'show')::int as episodes, sum(minutes)::int as minutes
|
||||
from (${base}) b group by 1 order by 1`,
|
||||
sql`select g as genre, count(*)::int as titles from movies.titles t, unnest(t.genres) g
|
||||
where t.status in ('watched','watching','dropped') group by 1 order by 2 desc, 1 limit 12`,
|
||||
sql`select who, count(*)::int as plays, sum(minutes)::int as minutes from (${base}) b group by 1 order by 2 desc`,
|
||||
sql`select platform, count(*)::int as plays, sum(minutes)::int as minutes from (${base}) b group by 1 order by 2 desc`,
|
||||
sql`select count(*)::int as plays, coalesce(sum(minutes), 0)::int as minutes,
|
||||
(select count(*) from movies.titles where type = 'movie' and status in ('watched', 'watching'))::int as movies,
|
||||
(select count(*) from movies.titles where type = 'show' and status in ('watched', 'watching'))::int as shows,
|
||||
count(*) filter (where type = 'show')::int as episodes from (${base}) b`
|
||||
]);
|
||||
return { months, years, genres, who, platforms, totals } as unknown as Stats;
|
||||
}
|
||||
|
||||
export async function loadRecs() {
|
||||
const rows =
|
||||
await sql`select id, tmdb_id, type, title, for_whom, reason, added_at, poster_path, year, genres
|
||||
from movies.recs order by added_at desc, id desc limit 200`;
|
||||
return rows.map((r) => ({
|
||||
id: r.id as number,
|
||||
tmdbId: r.tmdb_id as number,
|
||||
type: r.type as 'movie' | 'show',
|
||||
title: r.title as string,
|
||||
forWhom: r.for_whom as string,
|
||||
reason: r.reason as string | null,
|
||||
addedAt: new Date(r.added_at as string | Date).toISOString(),
|
||||
posterPath: r.poster_path as string | null,
|
||||
year: r.year as number | null,
|
||||
genres: r.genres as string[]
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- owner actions
|
||||
|
||||
type Result = { ok: true; id?: number } | { ok: false; message: string };
|
||||
const blank = (v: unknown) => (v === '' || v === null || v === undefined ? null : v);
|
||||
const opt = <T extends z.ZodType>(s: T) => z.preprocess(blank, s.nullable());
|
||||
const rating = z.coerce
|
||||
.number()
|
||||
.min(0.5)
|
||||
.max(5)
|
||||
.refine((n) => Number.isInteger(n * 2), 'rating must be in steps of 0.5');
|
||||
const date = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'date must be YYYY-MM-DD');
|
||||
|
||||
const updateInput = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
status: z.enum(MOVIE_STATUSES),
|
||||
rating: opt(rating),
|
||||
watched_by: opt(z.enum(MOVIE_WHO)),
|
||||
platform: opt(z.enum(MOVIE_PLATFORMS)),
|
||||
rewatch_count: z.coerce.number().int().min(0).max(1000),
|
||||
notes: opt(z.string().trim().max(2000))
|
||||
});
|
||||
|
||||
export async function updateTitle(form: FormData): Promise<Result> {
|
||||
const p = updateInput.safeParse(Object.fromEntries(form));
|
||||
if (!p.success) return { ok: false, message: p.error.issues[0]?.message ?? 'Invalid' };
|
||||
const v = p.data;
|
||||
await sql`update movies.titles set status = ${v.status}, rating = ${v.rating}, watched_by = ${v.watched_by},
|
||||
platform = ${v.platform}, rewatch_count = ${v.rewatch_count}, notes = ${v.notes} where id = ${v.id}`;
|
||||
return { ok: true, id: v.id };
|
||||
}
|
||||
|
||||
const addInput = z.object({
|
||||
type: z.enum(['movie', 'show']),
|
||||
title: z.string().trim().min(1, 'title is required').max(300),
|
||||
year: opt(z.coerce.number().int().min(1870).max(2100)),
|
||||
status: z.enum(MOVIE_STATUSES),
|
||||
rating: opt(rating),
|
||||
who: opt(z.enum(MOVIE_WHO)),
|
||||
platform: opt(z.enum(MOVIE_PLATFORMS)),
|
||||
watched_on: opt(date)
|
||||
});
|
||||
|
||||
export async function addTitle(form: FormData): Promise<Result> {
|
||||
const p = addInput.safeParse(Object.fromEntries(form));
|
||||
if (!p.success) return { ok: false, message: p.error.issues[0]?.message ?? 'Invalid' };
|
||||
const v = p.data;
|
||||
const who = v.who ?? 'stan';
|
||||
const slug = normTitle(v.title).replace(/ /g, '-') + (v.year ? `-${v.year}` : '');
|
||||
await importItems(sql, [
|
||||
{
|
||||
type: v.type,
|
||||
title: v.title,
|
||||
year: v.year,
|
||||
source: 'site',
|
||||
mode: 'set',
|
||||
status: v.status,
|
||||
rating: v.rating,
|
||||
who: v.who,
|
||||
platform: v.platform,
|
||||
plays:
|
||||
v.watched_on && v.type === 'movie'
|
||||
? [
|
||||
{
|
||||
watchedOn: v.watched_on,
|
||||
who,
|
||||
platform: v.platform,
|
||||
sourceKey: `site:${v.type}:${slug}:${v.watched_on}`
|
||||
}
|
||||
]
|
||||
: [],
|
||||
firstWatched: v.type === 'show' ? v.watched_on : null,
|
||||
lastWatched: v.type === 'show' ? v.watched_on : null
|
||||
}
|
||||
]);
|
||||
// Match right away when the app has a key; otherwise the next `movies-run.sh tmdb` does it.
|
||||
if (process.env.TMDB_API_KEY) {
|
||||
try {
|
||||
await runTmdb(sql, new Tmdb(process.env.TMDB_API_KEY), { limit: 5 });
|
||||
} catch {
|
||||
// Leave it pending.
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteTitle(form: FormData): Promise<Result> {
|
||||
const id = Number(form.get('id'));
|
||||
if (!Number.isInteger(id) || id <= 0) return { ok: false, message: 'Missing id' };
|
||||
await sql`delete from movies.titles where id = ${id}`;
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteRec(form: FormData): Promise<Result> {
|
||||
const id = Number(form.get('id'));
|
||||
if (!Number.isInteger(id) || id <= 0) return { ok: false, message: 'Missing id' };
|
||||
await sql`delete from movies.recs where id = ${id}`;
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function setVisibility(form: FormData): Promise<Result> {
|
||||
const v = form.get('visibility');
|
||||
if (v !== 'public' && v !== 'private')
|
||||
return { ok: false, message: 'visibility must be public or private' };
|
||||
await sql`insert into movies.settings (key, value) values ('visibility', ${v})
|
||||
on conflict (key) do update set value = excluded.value`;
|
||||
cached = null;
|
||||
return { ok: true };
|
||||
}
|
||||
240
src/lib/server/movies/store.ts
Normal file
240
src/lib/server/movies/store.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
// Movies & TV tracker: idempotent writes shared by the import scripts and the site's owner forms.
|
||||
// Takes a postgres.js client (no SvelteKit imports). Re-running an import changes nothing: plays
|
||||
// are keyed by source_key, titles by (type, tmdb_id) or match_key, episodes by (title, ep_key).
|
||||
import type { Sql, TransactionSql } from 'postgres';
|
||||
import {
|
||||
epKey,
|
||||
matchKey,
|
||||
normTitle,
|
||||
type ImportEpisode,
|
||||
type ImportItem,
|
||||
type RecItem
|
||||
} from './parse';
|
||||
|
||||
type Tx = Sql | TransactionSql;
|
||||
|
||||
export type ImportStats = {
|
||||
titles: number;
|
||||
newTitles: number;
|
||||
plays: number;
|
||||
newPlays: number;
|
||||
episodes: number;
|
||||
};
|
||||
|
||||
async function findTitle(
|
||||
tx: Tx,
|
||||
it: ImportItem
|
||||
): Promise<{ id: number; year: number | null; tmdb_id: number | null } | null> {
|
||||
if (it.tmdbId) {
|
||||
const [r] =
|
||||
await tx`select id, year, tmdb_id from movies.titles where type = ${it.type} and tmdb_id = ${it.tmdbId}`;
|
||||
if (r) return r as never;
|
||||
}
|
||||
const key = matchKey(it.type, it.title, it.year);
|
||||
const [exact] = await tx`select id, year, tmdb_id from movies.titles where match_key = ${key}`;
|
||||
if (exact && (!it.tmdbId || !exact.tmdb_id)) return exact as never;
|
||||
// Same title, year unknown on one side (Netflix has no years; Letterboxd and Plex do).
|
||||
const base = `${it.type}:${normTitle(it.title)}:`;
|
||||
const like = await tx`
|
||||
select id, year, tmdb_id from movies.titles
|
||||
where type = ${it.type} and (match_key = ${base} or (${it.year ?? null}::int is null and match_key like ${base.replace(/[%_\\]/g, '\\$&') + '%'}))
|
||||
${it.tmdbId ? tx`and (tmdb_id is null or tmdb_id = ${it.tmdbId})` : tx``}
|
||||
limit 2`;
|
||||
return like.length === 1 ? (like[0] as never) : null;
|
||||
}
|
||||
|
||||
async function freeKey(tx: Tx, key: string, suffix: string | number) {
|
||||
const [taken] = await tx`select 1 from movies.titles where match_key = ${key}`;
|
||||
return taken ? `${key}#${suffix}` : key;
|
||||
}
|
||||
|
||||
async function upsertEpisode(tx: Tx, titleId: number, e: ImportEpisode): Promise<number> {
|
||||
const key = epKey(e);
|
||||
const [row] = await tx`
|
||||
insert into movies.episodes (title_id, ep_key, season, episode, name)
|
||||
values (${titleId}, ${key}, ${e.season}, ${e.episode}, ${e.name})
|
||||
on conflict (title_id, ep_key) do update set
|
||||
name = coalesce(movies.episodes.name, excluded.name),
|
||||
season = coalesce(movies.episodes.season, excluded.season),
|
||||
episode = coalesce(movies.episodes.episode, excluded.episode)
|
||||
returning id`;
|
||||
if (e.episode == null && e.name) return row.id;
|
||||
// A numbered episode may also exist unnumbered (Netflix gives names, Plex numbers): fold it in.
|
||||
if (e.episode != null && e.name) {
|
||||
const nameKey = epKey({ season: e.season, episode: null, name: e.name });
|
||||
const [dup] =
|
||||
await tx`select id from movies.episodes where title_id = ${titleId} and ep_key = ${nameKey}`;
|
||||
if (dup) {
|
||||
await tx`update movies.plays set episode_id = ${row.id} where episode_id = ${dup.id}`;
|
||||
await tx`delete from movies.episodes where id = ${dup.id}`;
|
||||
}
|
||||
}
|
||||
return row.id;
|
||||
}
|
||||
|
||||
/** Insert or update one item (title + episodes + plays), then recompute the title's aggregates. */
|
||||
export async function upsertItem(tx: Tx, it: ImportItem, s: ImportStats): Promise<number> {
|
||||
const row = await findTitle(tx, it);
|
||||
let id: number;
|
||||
if (!row) {
|
||||
const key = await freeKey(tx, matchKey(it.type, it.title, it.year), it.tmdbId ?? Date.now());
|
||||
const status =
|
||||
it.status ?? (it.plays.length ? (it.type === 'show' ? 'watching' : 'watched') : 'watchlist');
|
||||
const [ins] = await tx`
|
||||
insert into movies.titles (type, tmdb_id, match_key, title, year, status, rating, rewatch_count,
|
||||
first_watched, last_watched, watched_by, platform, source, sources, notes, match_status)
|
||||
values (${it.type}, ${it.tmdbId ?? null}, ${key}, ${it.title.trim()}, ${it.year ?? null}, ${status},
|
||||
${it.rating ?? null}, ${it.rewatchCount ?? 0}, ${it.firstWatched ?? null}, ${it.lastWatched ?? null},
|
||||
${it.who ?? null}, ${it.platform ?? null}, ${it.source}, ${[it.source]}, ${it.notes ?? null},
|
||||
${it.tmdbId ? 'manual' : 'pending'})
|
||||
returning id`;
|
||||
id = ins.id;
|
||||
s.newTitles++;
|
||||
} else {
|
||||
id = row.id;
|
||||
if (it.mode === 'set') {
|
||||
await tx`
|
||||
update movies.titles set
|
||||
status = coalesce(${it.status ?? null}, status),
|
||||
rating = coalesce(${it.rating ?? null}::real, rating),
|
||||
rewatch_count = coalesce(${it.rewatchCount ?? null}::int, rewatch_count),
|
||||
first_watched = coalesce(${it.firstWatched ?? null}::date, first_watched),
|
||||
last_watched = coalesce(${it.lastWatched ?? null}::date, last_watched),
|
||||
watched_by = coalesce(${it.who ?? null}, watched_by),
|
||||
platform = coalesce(${it.platform ?? null}, platform),
|
||||
notes = coalesce(${it.notes ?? null}, notes),
|
||||
year = coalesce(year, ${it.year ?? null}::int),
|
||||
sources = case when ${it.source} = any(sources) then sources else sources || ${it.source}::text end
|
||||
where id = ${id}`;
|
||||
} else {
|
||||
await tx`
|
||||
update movies.titles set
|
||||
rating = coalesce(rating, ${it.rating ?? null}::real),
|
||||
platform = coalesce(platform, ${it.platform ?? null}),
|
||||
watched_by = coalesce(watched_by, ${it.who ?? null}),
|
||||
year = coalesce(year, ${it.year ?? null}::int),
|
||||
status = case when status = 'watchlist' and ${it.status ?? null}::text = 'watched' then 'watched' else status end,
|
||||
sources = case when ${it.source} = any(sources) then sources else sources || ${it.source}::text end
|
||||
where id = ${id}`;
|
||||
}
|
||||
if (it.tmdbId && !row.tmdb_id) {
|
||||
const [clash] =
|
||||
await tx`select id from movies.titles where type = ${it.type} and tmdb_id = ${it.tmdbId}`;
|
||||
if (clash) id = await mergeTitles(tx, clash.id, id);
|
||||
else
|
||||
await tx`update movies.titles set tmdb_id = ${it.tmdbId}, match_status = 'manual' where id = ${id}`;
|
||||
}
|
||||
}
|
||||
s.titles++;
|
||||
const epIds = new Map<string, number>();
|
||||
for (const e of it.episodes ?? []) {
|
||||
epIds.set(epKey(e), await upsertEpisode(tx, id, e));
|
||||
s.episodes++;
|
||||
}
|
||||
for (const p of it.plays) {
|
||||
let episodeId: number | null = null;
|
||||
if (p.episode)
|
||||
episodeId = epIds.get(epKey(p.episode)) ?? (await upsertEpisode(tx, id, p.episode));
|
||||
const res = await tx`
|
||||
insert into movies.plays (title_id, episode_id, watched_on, watched_at, duration_min, who, platform, source, source_key)
|
||||
values (${id}, ${episodeId}, ${p.watchedOn}, ${p.watchedAt ?? null}, ${p.durationMin ?? null}, ${p.who},
|
||||
${p.platform}, ${it.source}, ${p.sourceKey})
|
||||
on conflict (source_key) do nothing`;
|
||||
s.plays++;
|
||||
s.newPlays += res.count;
|
||||
}
|
||||
await recompute(tx, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Derive dates, rewatches, who, platform and status from the plays (never undoes manual values). */
|
||||
export async function recompute(tx: Tx, id: number) {
|
||||
await tx`
|
||||
with p as (
|
||||
select min(watched_on) as first, max(watched_on) as last, count(distinct watched_on) as days,
|
||||
count(*) filter (where who in ('stan','both')) as stan, count(*) filter (where who in ('juhee','both')) as juhee,
|
||||
count(distinct episode_id) as eps,
|
||||
mode() within group (order by platform) filter (where platform is not null) as platform
|
||||
from movies.plays where title_id = ${id}
|
||||
)
|
||||
update movies.titles t set
|
||||
first_watched = least(t.first_watched, p.first),
|
||||
last_watched = greatest(t.last_watched, p.last),
|
||||
rewatch_count = case when t.type = 'movie' then greatest(t.rewatch_count, p.days - 1) else t.rewatch_count end,
|
||||
watched_by = case
|
||||
when p.stan > 0 and p.juhee > 0 then 'both'
|
||||
when t.watched_by = 'stan' and p.juhee > 0 then 'both'
|
||||
when t.watched_by = 'juhee' and p.stan > 0 then 'both'
|
||||
when t.watched_by is null and p.stan > 0 then 'stan'
|
||||
when t.watched_by is null and p.juhee > 0 then 'juhee'
|
||||
else t.watched_by end,
|
||||
platform = coalesce(t.platform, p.platform),
|
||||
status = case
|
||||
when t.status = 'watchlist' and p.days > 0 then (case when t.type = 'show' then 'watching' else 'watched' end)
|
||||
when t.type = 'show' and t.status = 'watching' and t.total_episodes > 0 and p.eps >= t.total_episodes then 'watched'
|
||||
else t.status end
|
||||
from p where t.id = ${id}`;
|
||||
}
|
||||
|
||||
/** Fold `dropId` into `keepId` (same TMDB title from two sources). Returns keepId. */
|
||||
export async function mergeTitles(tx: Tx, keepId: number, dropId: number): Promise<number> {
|
||||
if (keepId === dropId) return keepId;
|
||||
const eps = await tx`select id, ep_key from movies.episodes where title_id = ${dropId}`;
|
||||
for (const e of eps) {
|
||||
const [same] =
|
||||
await tx`select id from movies.episodes where title_id = ${keepId} and ep_key = ${e.ep_key}`;
|
||||
if (same) {
|
||||
await tx`update movies.plays set episode_id = ${same.id} where episode_id = ${e.id}`;
|
||||
await tx`delete from movies.episodes where id = ${e.id}`;
|
||||
} else await tx`update movies.episodes set title_id = ${keepId} where id = ${e.id}`;
|
||||
}
|
||||
await tx`update movies.plays set title_id = ${keepId} where title_id = ${dropId}`;
|
||||
await tx`
|
||||
update movies.titles k set
|
||||
rating = coalesce(k.rating, d.rating),
|
||||
status = case when k.status = 'watchlist' then d.status else k.status end,
|
||||
rewatch_count = greatest(k.rewatch_count, d.rewatch_count),
|
||||
first_watched = least(k.first_watched, d.first_watched),
|
||||
last_watched = greatest(k.last_watched, d.last_watched),
|
||||
watched_by = case when k.watched_by is null then d.watched_by when d.watched_by is null or d.watched_by = k.watched_by then k.watched_by else 'both' end,
|
||||
platform = coalesce(k.platform, d.platform),
|
||||
year = coalesce(k.year, d.year),
|
||||
notes = coalesce(k.notes, d.notes),
|
||||
sources = array(select distinct unnest(k.sources || d.sources))
|
||||
from movies.titles d where k.id = ${keepId} and d.id = ${dropId}`;
|
||||
await tx`delete from movies.titles where id = ${dropId}`;
|
||||
await recompute(tx, keepId);
|
||||
return keepId;
|
||||
}
|
||||
|
||||
export async function importItems(sql: Sql, items: ImportItem[]): Promise<ImportStats> {
|
||||
const s: ImportStats = { titles: 0, newTitles: 0, plays: 0, newPlays: 0, episodes: 0 };
|
||||
await sql.begin(async (tx) => {
|
||||
for (const it of items) await upsertItem(tx, it, s);
|
||||
});
|
||||
return s;
|
||||
}
|
||||
|
||||
export async function upsertRecs(
|
||||
sql: Sql,
|
||||
recs: RecItem[],
|
||||
prune = false
|
||||
): Promise<{ upserted: number; pruned: number }> {
|
||||
let pruned = 0;
|
||||
await sql.begin(async (tx) => {
|
||||
for (const r of recs)
|
||||
await tx`
|
||||
insert into movies.recs (tmdb_id, type, title, for_whom, reason, added_at)
|
||||
values (${r.tmdb_id}, ${r.type}, ${r.title}, ${r.for}, ${r.reason ?? null}, ${r.added_at ? new Date(r.added_at) : new Date()})
|
||||
on conflict (type, tmdb_id) do update set title = excluded.title, for_whom = excluded.for_whom,
|
||||
reason = excluded.reason,
|
||||
added_at = coalesce(${r.added_at ? new Date(r.added_at) : null}::timestamptz, movies.recs.added_at)`;
|
||||
if (prune) {
|
||||
const keep = recs.map((r) => `${r.type}:${r.tmdb_id}`);
|
||||
const res =
|
||||
await tx`delete from movies.recs where not ((type || ':' || tmdb_id) = any(${keep}))`;
|
||||
pruned = res.count;
|
||||
}
|
||||
});
|
||||
return { upserted: recs.length, pruned };
|
||||
}
|
||||
278
src/lib/server/movies/tmdb.ts
Normal file
278
src/lib/server/movies/tmdb.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
// Movies & TV tracker: TMDB matching and metadata (posters, genres, year, runtime, cast).
|
||||
// Only scripts (and, when the app env has TMDB_API_KEY, the owner's manual add) call this; pages
|
||||
// read the cached columns. Accepts a v3 API key or a v4 read access token ("eyJ…").
|
||||
// This product uses the TMDB API but is not endorsed or certified by TMDB.
|
||||
import type { Sql, TransactionSql } from 'postgres';
|
||||
import { mergeTitles, recompute } from './store';
|
||||
import { normTitle } from './parse';
|
||||
|
||||
type Tx = Sql | TransactionSql;
|
||||
const API = 'https://api.themoviedb.org/3';
|
||||
|
||||
export class Tmdb {
|
||||
private last = 0;
|
||||
constructor(private key: string) {
|
||||
if (!key) throw new Error('TMDB_API_KEY is empty');
|
||||
}
|
||||
async get<T>(path: string, params: Record<string, string | number | undefined> = {}): Promise<T> {
|
||||
const url = new URL(API + path);
|
||||
for (const [k, v] of Object.entries(params))
|
||||
if (v !== undefined && v !== '') url.searchParams.set(k, String(v));
|
||||
const bearer = this.key.startsWith('eyJ');
|
||||
if (!bearer) url.searchParams.set('api_key', this.key);
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
// ~20 requests/s, well under TMDB's limits.
|
||||
const wait = this.last + 50 - Date.now();
|
||||
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
||||
this.last = Date.now();
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
...(bearer ? { authorization: `Bearer ${this.key}` } : {})
|
||||
}
|
||||
});
|
||||
if (res.status === 429 || res.status >= 500) {
|
||||
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
|
||||
continue;
|
||||
}
|
||||
// Never include the URL in errors: it can carry the api_key.
|
||||
if (res.status === 401) throw new Error('TMDB rejected the API key (401)');
|
||||
if (res.status === 404) throw Object.assign(new Error('TMDB 404'), { status: 404 });
|
||||
if (!res.ok) throw new Error(`TMDB ${path.split('?')[0]} failed: ${res.status}`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
throw new Error(`TMDB ${path} kept failing`);
|
||||
}
|
||||
}
|
||||
|
||||
type SearchHit = {
|
||||
id: number;
|
||||
title?: string;
|
||||
name?: string;
|
||||
original_title?: string;
|
||||
original_name?: string;
|
||||
release_date?: string;
|
||||
first_air_date?: string;
|
||||
popularity?: number;
|
||||
vote_count?: number;
|
||||
};
|
||||
|
||||
const yearOf = (d?: string) => (d && /^\d{4}/.test(d) ? Number(d.slice(0, 4)) : null);
|
||||
|
||||
/** Best TMDB hit for a title: exact normalised title (and year, ±1) first, then the top result. */
|
||||
export async function searchTmdb(
|
||||
t: Tmdb,
|
||||
type: 'movie' | 'show',
|
||||
title: string,
|
||||
year: number | null
|
||||
) {
|
||||
const path = type === 'movie' ? '/search/movie' : '/search/tv';
|
||||
const yearParam = type === 'movie' ? 'year' : 'first_air_date_year';
|
||||
let { results } = await t.get<{ results: SearchHit[] }>(path, {
|
||||
query: title,
|
||||
[yearParam]: year ?? undefined,
|
||||
include_adult: 'false'
|
||||
});
|
||||
if (!results.length && year)
|
||||
({ results } = await t.get<{ results: SearchHit[] }>(path, {
|
||||
query: title,
|
||||
include_adult: 'false'
|
||||
}));
|
||||
if (!results.length) return null;
|
||||
const want = normTitle(title);
|
||||
const score = (h: SearchHit) => {
|
||||
const names = [h.title, h.name, h.original_title, h.original_name]
|
||||
.filter(Boolean)
|
||||
.map((n) => normTitle(n!));
|
||||
const y = yearOf(h.release_date ?? h.first_air_date);
|
||||
let s = names.includes(want) ? 10 : 0;
|
||||
if (year && y) s += y === year ? 5 : Math.abs(y - year) === 1 ? 2 : -5;
|
||||
s += Math.min(3, Math.log10((h.vote_count ?? 0) + 1));
|
||||
return s;
|
||||
};
|
||||
const best = [...results.slice(0, 10)].sort((a, b) => score(b) - score(a))[0];
|
||||
if (score(best) >= 10) return best;
|
||||
// No exact title: accept the top hit only when its year agrees (±1) or, without a year, when
|
||||
// one title contains the other (localised or subtitled names). Otherwise leave it unmatched.
|
||||
const top = results[0];
|
||||
const topYear = yearOf(top.release_date ?? top.first_air_date);
|
||||
if (year) return topYear && Math.abs(topYear - year) <= 1 ? top : null;
|
||||
const names = [top.title, top.name, top.original_title, top.original_name]
|
||||
.filter(Boolean)
|
||||
.map((n) => normTitle(n!));
|
||||
return names.some((n) => n && (n.includes(want) || want.includes(n))) ? top : null;
|
||||
}
|
||||
|
||||
type Details = {
|
||||
id: number;
|
||||
title?: string;
|
||||
name?: string;
|
||||
original_title?: string;
|
||||
original_name?: string;
|
||||
release_date?: string;
|
||||
first_air_date?: string;
|
||||
overview?: string;
|
||||
poster_path?: string | null;
|
||||
backdrop_path?: string | null;
|
||||
genres?: { name: string }[];
|
||||
runtime?: number | null;
|
||||
episode_run_time?: number[];
|
||||
number_of_seasons?: number;
|
||||
number_of_episodes?: number;
|
||||
last_episode_to_air?: { runtime?: number | null } | null;
|
||||
credits?: { cast?: { name: string; order: number }[] };
|
||||
};
|
||||
|
||||
export async function details(t: Tmdb, type: 'movie' | 'show', id: number): Promise<Details> {
|
||||
return t.get<Details>(`/${type === 'movie' ? 'movie' : 'tv'}/${id}`, {
|
||||
append_to_response: 'credits',
|
||||
language: 'en-US'
|
||||
});
|
||||
}
|
||||
|
||||
async function saveDetails(
|
||||
tx: Tx,
|
||||
titleId: number,
|
||||
type: 'movie' | 'show',
|
||||
d: Details,
|
||||
status: 'matched' | 'manual'
|
||||
) {
|
||||
const runtime =
|
||||
type === 'movie'
|
||||
? (d.runtime ?? null)
|
||||
: (d.episode_run_time?.[0] ?? d.last_episode_to_air?.runtime ?? null);
|
||||
const cast = (d.credits?.cast ?? [])
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.slice(0, 8)
|
||||
.map((c) => c.name);
|
||||
await tx`
|
||||
update movies.titles set
|
||||
tmdb_id = ${d.id}, match_status = ${status},
|
||||
original_title = ${d.original_title ?? d.original_name ?? null},
|
||||
year = coalesce(${yearOf(d.release_date ?? d.first_air_date)}::int, year),
|
||||
overview = ${d.overview || null}, poster_path = ${d.poster_path ?? null}, backdrop_path = ${d.backdrop_path ?? null},
|
||||
genres = ${(d.genres ?? []).map((g) => g.name)}, "cast" = ${cast}, runtime_min = ${runtime || null},
|
||||
total_seasons = ${d.number_of_seasons ?? null}, total_episodes = ${d.number_of_episodes ?? null},
|
||||
metadata_at = now()
|
||||
where id = ${titleId}`;
|
||||
}
|
||||
|
||||
/** Give Netflix's unnumbered episodes (season + name) their TMDB episode numbers and runtimes. */
|
||||
async function numberEpisodes(t: Tmdb, tx: Tx, titleId: number, tvId: number) {
|
||||
const seasons =
|
||||
await tx`select distinct season from movies.episodes where title_id = ${titleId} and season is not null`;
|
||||
for (const { season } of seasons) {
|
||||
let eps: { episode_number: number; name: string; runtime: number | null }[];
|
||||
try {
|
||||
({ episodes: eps } = await t.get<{ episodes: typeof eps }>(`/tv/${tvId}/season/${season}`));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const byName = new Map(eps.map((e) => [normTitle(e.name), e]));
|
||||
for (const e of eps)
|
||||
await tx`update movies.episodes set runtime_min = coalesce(runtime_min, ${e.runtime ?? null}::int)
|
||||
where title_id = ${titleId} and season = ${season} and episode = ${e.episode_number}`;
|
||||
const unnumbered =
|
||||
await tx`select id, name from movies.episodes where title_id = ${titleId} and season = ${season} and episode is null`;
|
||||
for (const u of unnumbered) {
|
||||
const hit = byName.get(normTitle(u.name ?? ''));
|
||||
if (!hit) continue;
|
||||
const key = `s${season}e${hit.episode_number}`;
|
||||
const [dup] =
|
||||
await tx`select id from movies.episodes where title_id = ${titleId} and ep_key = ${key}`;
|
||||
if (dup) {
|
||||
await tx`update movies.plays set episode_id = ${dup.id} where episode_id = ${u.id}`;
|
||||
await tx`delete from movies.episodes where id = ${u.id}`;
|
||||
} else
|
||||
await tx`update movies.episodes set episode = ${hit.episode_number}, ep_key = ${key}, runtime_min = coalesce(runtime_min, ${hit.runtime ?? null}::int) where id = ${u.id}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type MatchStats = {
|
||||
tried: number;
|
||||
matched: number;
|
||||
notFound: number;
|
||||
merged: number;
|
||||
refreshed: number;
|
||||
errors: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Match pending titles to TMDB, fetch metadata for matched titles without it (or older than
|
||||
* `refreshDays`), merge duplicates that turn out to be the same TMDB title, and fill recs posters.
|
||||
*/
|
||||
export async function runTmdb(
|
||||
sql: Sql,
|
||||
t: Tmdb,
|
||||
o: {
|
||||
limit?: number;
|
||||
retryNotFound?: boolean;
|
||||
refreshDays?: number;
|
||||
log?: (s: string) => void;
|
||||
} = {}
|
||||
): Promise<MatchStats> {
|
||||
const s: MatchStats = { tried: 0, matched: 0, notFound: 0, merged: 0, refreshed: 0, errors: 0 };
|
||||
const log = o.log ?? (() => {});
|
||||
const statuses = o.retryNotFound ? ['pending', 'not_found'] : ['pending'];
|
||||
const pending =
|
||||
await sql`select id, type, title, year from movies.titles where tmdb_id is null and match_status = any(${statuses}) order by id limit ${o.limit ?? 5000}`;
|
||||
for (const p of pending) {
|
||||
s.tried++;
|
||||
try {
|
||||
const hit = await searchTmdb(t, p.type, p.title, p.year);
|
||||
if (!hit) {
|
||||
await sql`update movies.titles set match_status = 'not_found' where id = ${p.id}`;
|
||||
s.notFound++;
|
||||
log(`not found: ${p.type} "${p.title}"${p.year ? ` (${p.year})` : ''}`);
|
||||
continue;
|
||||
}
|
||||
await sql.begin(async (tx) => {
|
||||
const [owner] =
|
||||
await tx`select id from movies.titles where type = ${p.type} and tmdb_id = ${hit.id} and id <> ${p.id}`;
|
||||
if (owner) {
|
||||
await mergeTitles(tx, owner.id, p.id);
|
||||
s.merged++;
|
||||
} else {
|
||||
await tx`update movies.titles set tmdb_id = ${hit.id}, match_status = 'matched' where id = ${p.id}`;
|
||||
s.matched++;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
s.errors++;
|
||||
log(`error matching "${p.title}": ${(e as Error).message}`);
|
||||
if (/401/.test((e as Error).message)) throw e;
|
||||
}
|
||||
}
|
||||
const stale = await sql`
|
||||
select id, type, tmdb_id, match_status from movies.titles
|
||||
where tmdb_id is not null and (metadata_at is null or metadata_at < now() - make_interval(days => ${o.refreshDays ?? 90}))
|
||||
order by metadata_at nulls first limit ${o.limit ?? 5000}`;
|
||||
for (const r of stale) {
|
||||
try {
|
||||
const d = await details(t, r.type, r.tmdb_id);
|
||||
await sql.begin(async (tx) => {
|
||||
await saveDetails(tx, r.id, r.type, d, r.match_status === 'manual' ? 'manual' : 'matched');
|
||||
if (r.type === 'show') await numberEpisodes(t, tx, r.id, r.tmdb_id);
|
||||
await recompute(tx, r.id);
|
||||
});
|
||||
s.refreshed++;
|
||||
} catch (e) {
|
||||
s.errors++;
|
||||
log(`error fetching ${r.type} ${r.tmdb_id}: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
const recs =
|
||||
await sql`select id, type, tmdb_id from movies.recs where metadata_at is null or metadata_at < now() - interval '90 days'`;
|
||||
for (const r of recs) {
|
||||
try {
|
||||
const d = await details(t, r.type, r.tmdb_id);
|
||||
await sql`update movies.recs set poster_path = ${d.poster_path ?? null}, year = ${yearOf(d.release_date ?? d.first_air_date)},
|
||||
genres = ${(d.genres ?? []).map((g) => g.name)}, metadata_at = now() where id = ${r.id}`;
|
||||
} catch (e) {
|
||||
s.errors++;
|
||||
log(`error fetching rec ${r.type} ${r.tmdb_id}: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
@@ -59,7 +59,8 @@ export const ownerWritePrefixes = [
|
||||
'/trackers/wildlife',
|
||||
'/trackers/music',
|
||||
'/trackers/pc',
|
||||
'/trackers/ai'
|
||||
'/trackers/ai',
|
||||
'/trackers/movies'
|
||||
] as const;
|
||||
|
||||
const normalise = (pathname: string) => pathname.toLowerCase().replace(/\/+$/, '');
|
||||
|
||||
@@ -68,6 +68,18 @@ export const trackerIcons: Record<string, TrackerIcon> = {
|
||||
ink: WHITE,
|
||||
paths: ['M9 18V5l12-2v13', c(6, 18, 3), c(18, 16, 3)]
|
||||
},
|
||||
movies: {
|
||||
from: '#fbbf24',
|
||||
to: '#f2552c',
|
||||
ink: WHITE,
|
||||
// clapperboard
|
||||
paths: [
|
||||
'M20.2 6 3 11l-.9-2.4c-.3-1.1.3-2.2 1.3-2.5l13.5-4c1.1-.3 2.2.3 2.5 1.3Z',
|
||||
'm6.2 5.3 3.1 3.9',
|
||||
'm12.4 3.4 3.1 4',
|
||||
'M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z'
|
||||
]
|
||||
},
|
||||
pc: {
|
||||
from: '#22d3ee',
|
||||
to: '#0891b2',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isAdmin } from '#lib/server/authorization.js';
|
||||
import { isOwner } from '#lib/server/owner.js';
|
||||
import { loadInactiveGames } from '#lib/server/games/active.js';
|
||||
import { loadHiddenTrackers } from '#lib/server/movies/site.js';
|
||||
import { dev } from '$app/env';
|
||||
import { siteEnvironment } from '#lib/title.js';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
@@ -15,5 +16,7 @@ export const load: LayoutServerLoad = async (event) => ({
|
||||
version: process.env.APP_VERSION || 'dev',
|
||||
environment,
|
||||
/** Games I don't play now: navigation lists them under "non-active" (#lib/projects). */
|
||||
inactiveGames: await loadInactiveGames()
|
||||
inactiveGames: await loadInactiveGames(),
|
||||
/** Trackers switched to private by their owner setting (movies): hidden from everyone else. */
|
||||
hiddenTrackers: await loadHiddenTrackers()
|
||||
});
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
>
|
||||
</h2>
|
||||
<CatalogGrid
|
||||
items={visibleTrackers(data.isOwner, data.inactiveGames)}
|
||||
items={visibleTrackers(data.isOwner, data.inactiveGames, data.hiddenTrackers)}
|
||||
variant="tracker"
|
||||
subtitleClass="mt-1 block text-base text-[var(--site-muted)]"
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { races } from '#lib/derby/races.js';
|
||||
import { COURSES } from '#lib/language-learner/courses/index.js';
|
||||
import { gameTrackers } from '#lib/projects.js';
|
||||
import { and, eq, isNull, ne } from 'drizzle-orm';
|
||||
import { getVisibility } from '#lib/server/movies/site.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
const staticPaths = [
|
||||
@@ -41,6 +42,7 @@ const staticPaths = [
|
||||
'/trackers/wildlife',
|
||||
'/trackers/wildlife/groups',
|
||||
'/trackers/music',
|
||||
'/trackers/movies',
|
||||
'/trackers/pc',
|
||||
'/trackers/games',
|
||||
...gameTrackers.map((g) => g.href),
|
||||
@@ -68,7 +70,7 @@ function escapeXml(value: string): string {
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
const [articles, lists, books] = await Promise.all([
|
||||
const [articles, lists, books, moviesVisibility] = await Promise.all([
|
||||
db
|
||||
.select({ slug: blogArticles.slug, updatedAt: blogArticles.updatedAt })
|
||||
.from(blogArticles)
|
||||
@@ -78,10 +80,13 @@ export const GET: RequestHandler = async () => {
|
||||
.select({ id: bookWorks.id, updatedAt: bookWorks.updatedAt })
|
||||
.from(bookWorks)
|
||||
// Articles are links to other sites; their pages here are thin.
|
||||
.where(and(ne(bookWorks.kind, 'article'), isNull(bookWorks.archivedAt)))
|
||||
.where(and(ne(bookWorks.kind, 'article'), isNull(bookWorks.archivedAt))),
|
||||
getVisibility()
|
||||
]);
|
||||
const entries: { path: string; lastmod?: string }[] = [
|
||||
...staticPaths.map((path) => ({ path })),
|
||||
...staticPaths
|
||||
.filter((path) => moviesVisibility === 'public' || path !== '/trackers/movies')
|
||||
.map((path) => ({ path })),
|
||||
...races.map((race) => ({ path: `/projects/derby/${race.slug}` })),
|
||||
...articles.map((article) => ({
|
||||
path: `/blog/${encodeURIComponent(article.slug)}`,
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
// Same list, order and icons as the header Trackers dropdown (owner-only ones only when
|
||||
// the header shows them). Spotting is a group tile with one-click chips per sub-tracker.
|
||||
const trackers = $derived(visibleTrackers(data.isOwner, data.inactiveGames));
|
||||
const trackers = $derived(visibleTrackers(data.isOwner, data.inactiveGames, data.hiddenTrackers));
|
||||
// Search-only shortcuts that aren't trackers of their own.
|
||||
const extraHits = [
|
||||
{ name: 'Weigh-in', href: '/trackers/health#body' },
|
||||
|
||||
@@ -10,5 +10,9 @@
|
||||
<p class="mb-10 text-[var(--site-muted)]">
|
||||
Personal data I keep track of: games, money, houses, books, prices, animals and lists.
|
||||
</p>
|
||||
<CatalogGrid items={visibleTrackers(data.isOwner, data.inactiveGames)} variant="tracker" wide />
|
||||
<CatalogGrid
|
||||
items={visibleTrackers(data.isOwner, data.inactiveGames, data.hiddenTrackers)}
|
||||
variant="tracker"
|
||||
wide
|
||||
/>
|
||||
</div>
|
||||
|
||||
52
src/routes/trackers/movies/+page.server.ts
Normal file
52
src/routes/trackers/movies/+page.server.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { isOwner, requireOwner } from '#lib/server/owner.js';
|
||||
import {
|
||||
addTitle,
|
||||
deleteRec,
|
||||
deleteTitle,
|
||||
getVisibility,
|
||||
loadRecs,
|
||||
loadStats,
|
||||
loadTitles,
|
||||
setVisibility,
|
||||
updateTitle
|
||||
} from '#lib/server/movies/site.js';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
// Movies & TV: public by default; the owner's visibility switch can make it owner-only.
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
const owner = isOwner(event);
|
||||
const visibility = await getVisibility();
|
||||
if (visibility === 'private' && !owner) error(404, 'Not found');
|
||||
if (owner) event.setHeaders({ 'cache-control': 'private, no-store' });
|
||||
const [titles, stats, recs] = await Promise.all([loadTitles(), loadStats(), loadRecs()]);
|
||||
return {
|
||||
owner,
|
||||
visibility,
|
||||
titles,
|
||||
stats,
|
||||
recs,
|
||||
seo: {
|
||||
title: 'Movies & TV tracker',
|
||||
description:
|
||||
'The movies and TV shows we watch: what we are watching, ratings, rewatches and stats per month, genre and platform.'
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
type Result = { ok: true; id?: number } | { ok: false; message: string };
|
||||
const run =
|
||||
(fn: (form: FormData) => Promise<Result>) => async (event: Parameters<Actions[string]>[0]) => {
|
||||
requireOwner(event);
|
||||
const res = await fn(await event.request.formData());
|
||||
if (!res.ok) return fail(400, { message: res.message });
|
||||
return res;
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
update: run(updateTitle),
|
||||
add: run(addTitle),
|
||||
delete: run(deleteTitle),
|
||||
deleteRec: run(deleteRec),
|
||||
visibility: run(setVisibility)
|
||||
};
|
||||
1529
src/routes/trackers/movies/+page.svelte
Normal file
1529
src/routes/trackers/movies/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user