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

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

View File

@@ -32,7 +32,8 @@ export default defineConfig({
'trains',
'dataholders',
'stats',
'ai'
'ai',
'movies'
],
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
verbose: true,

View File

@@ -0,0 +1,142 @@
CREATE SCHEMA "movies";
--> statement-breakpoint
CREATE TABLE "movies"."episodes" (
"id" serial PRIMARY KEY,
"title_id" integer NOT NULL,
"ep_key" text NOT NULL,
"season" integer,
"episode" integer,
"name" text,
"runtime_min" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "movies"."plays" (
"id" serial PRIMARY KEY,
"title_id" integer NOT NULL,
"episode_id" integer,
"watched_on" date NOT NULL,
"watched_at" timestamp with time zone,
"duration_min" integer,
"who" text DEFAULT 'stan' NOT NULL,
"platform" text,
"source" text NOT NULL,
"source_key" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "plays_who_check" CHECK ("who" IN ('stan','juhee','both')),
CONSTRAINT "plays_platform_check" CHECK ("platform" IS NULL OR "platform" IN ('netflix','plex','cinema','other'))
);
--> statement-breakpoint
CREATE TABLE "movies"."recs" (
"id" serial PRIMARY KEY,
"tmdb_id" integer NOT NULL,
"type" text NOT NULL,
"title" text NOT NULL,
"for_whom" text NOT NULL,
"reason" text,
"added_at" timestamp with time zone DEFAULT now() NOT NULL,
"poster_path" text,
"year" integer,
"genres" text[] DEFAULT '{}'::text[] NOT NULL,
"metadata_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "recs_type_check" CHECK ("type" IN ('movie','show')),
CONSTRAINT "recs_for_check" CHECK ("for_whom" IN ('stan','juhee','both')),
CONSTRAINT "recs_title_check" CHECK (length(btrim("title")) > 0)
);
--> statement-breakpoint
CREATE TABLE "movies"."settings" (
"key" text PRIMARY KEY,
"value" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "settings_key_check" CHECK ("key" IN ('visibility'))
);
--> statement-breakpoint
CREATE TABLE "movies"."titles" (
"id" serial PRIMARY KEY,
"type" text NOT NULL,
"tmdb_id" integer,
"match_key" text NOT NULL,
"title" text NOT NULL,
"original_title" text,
"year" integer,
"status" text DEFAULT 'watched' NOT NULL,
"rating" real,
"rewatch_count" integer DEFAULT 0 NOT NULL,
"first_watched" date,
"last_watched" date,
"watched_by" text,
"platform" text,
"source" text NOT NULL,
"sources" text[] DEFAULT '{}'::text[] NOT NULL,
"notes" text,
"match_status" text DEFAULT 'pending' NOT NULL,
"poster_path" text,
"backdrop_path" text,
"overview" text,
"genres" text[] DEFAULT '{}'::text[] NOT NULL,
"cast" text[] DEFAULT '{}'::text[] NOT NULL,
"runtime_min" integer,
"total_seasons" integer,
"total_episodes" integer,
"metadata_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "titles_type_check" CHECK ("type" IN ('movie','show')),
CONSTRAINT "titles_status_check" CHECK ("status" IN ('watching','watched','watchlist','dropped')),
CONSTRAINT "titles_rating_check" CHECK ("rating" IS NULL OR ("rating" >= 0.5 AND "rating" <= 5 AND "rating" * 2 = round("rating" * 2))),
CONSTRAINT "titles_rewatch_check" CHECK ("rewatch_count" >= 0),
CONSTRAINT "titles_who_check" CHECK ("watched_by" IS NULL OR "watched_by" IN ('stan','juhee','both')),
CONSTRAINT "titles_platform_check" CHECK ("platform" IS NULL OR "platform" IN ('netflix','plex','cinema','other')),
CONSTRAINT "titles_source_check" CHECK ("source" IN ('netflix','plex','letterboxd','manual','ju','site')),
CONSTRAINT "titles_match_check" CHECK ("match_status" IN ('pending','matched','manual','not_found')),
CONSTRAINT "titles_title_check" CHECK (length(btrim("title")) > 0)
);
--> statement-breakpoint
CREATE UNIQUE INDEX "episodes_title_key" ON "movies"."episodes" ("title_id","ep_key");--> statement-breakpoint
CREATE INDEX "episodes_title_idx" ON "movies"."episodes" ("title_id","season","episode");--> statement-breakpoint
CREATE UNIQUE INDEX "plays_source_key" ON "movies"."plays" ("source_key");--> statement-breakpoint
CREATE INDEX "plays_title_idx" ON "movies"."plays" ("title_id");--> statement-breakpoint
CREATE INDEX "plays_watched_idx" ON "movies"."plays" ("watched_on");--> statement-breakpoint
CREATE UNIQUE INDEX "recs_tmdb_key" ON "movies"."recs" ("type","tmdb_id");--> statement-breakpoint
CREATE UNIQUE INDEX "titles_tmdb_key" ON "movies"."titles" ("type","tmdb_id") WHERE "tmdb_id" IS NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "titles_match_key" ON "movies"."titles" ("match_key");--> statement-breakpoint
CREATE INDEX "titles_status_idx" ON "movies"."titles" ("status");--> statement-breakpoint
ALTER TABLE "movies"."episodes" ADD CONSTRAINT "episodes_title_id_titles_id_fkey" FOREIGN KEY ("title_id") REFERENCES "movies"."titles"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "movies"."plays" ADD CONSTRAINT "plays_title_id_titles_id_fkey" FOREIGN KEY ("title_id") REFERENCES "movies"."titles"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "movies"."plays" ADD CONSTRAINT "plays_episode_id_episodes_id_fkey" FOREIGN KEY ("episode_id") REFERENCES "movies"."episodes"("id") ON DELETE CASCADE;--> statement-breakpoint
-- Hand-written below this line (not generated by drizzle-kit).
-- updated_at triggers, same as every other core table.
DO $$
DECLARE t text;
BEGIN
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
FOREACH t IN ARRAY ARRAY['titles','episodes','plays','recs','settings'] LOOP
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON movies.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
END LOOP;
END IF;
END $$;
--> statement-breakpoint
-- Public by default; the owner can switch the tracker to private on /trackers/movies.
INSERT INTO movies.settings (key, value) VALUES ('visibility', 'public') ON CONFLICT (key) DO NOTHING;
--> statement-breakpoint
-- Grants: app role read/write only (like ai and tasks; no personal_ro). Prod only, where
-- migrations run as `personal`.
REVOKE ALL ON SCHEMA movies FROM PUBLIC;
--> statement-breakpoint
DO $$
BEGIN
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT USAGE ON SCHEMA movies TO personal_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA movies TO personal_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA movies TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA movies
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA movies
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -211,6 +211,30 @@ try {
ai.public_usage
)
throw new Error(`ai schema tables/grants are wrong: ${JSON.stringify(ai)}`);
// Movies & TV tracker (public pages, owner-only edits in the app): app role read/write,
// personal_ro none. 5 = titles, episodes, plays, recs, settings.
const MOVIES_TABLES = 5;
const [movies] = await client`
select
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
count(*) filter (
where exists (
select 1 from aclexplode(c.relacl) a join pg_roles r on r.oid = a.grantee
where r.rolname = 'personal_ro'
)
) as ro_read,
count(*) as tables,
has_schema_privilege('public', 'movies', 'usage') as public_usage
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'movies' and c.relkind = 'r'
`;
if (
Number(movies.tables) !== MOVIES_TABLES ||
Number(movies.app_rw) !== MOVIES_TABLES ||
Number(movies.ro_read) !== 0 ||
movies.public_usage
)
throw new Error(`movies schema tables/grants are wrong: ${JSON.stringify(movies)}`);
// Energy tracker reads the ned-collector tables (SELECT only).
const [energy] = await client`
select count(*) as hours, max(valid_from) as latest from energy.ned_utilization
@@ -220,7 +244,7 @@ try {
if (!Number(energy.hours))
console.warn('Warning: energy.ned_utilization has no hourly data from the last 7 days');
console.log(
`health: ${health.tables} tables, tasks: ${tasks.tables} tables, groceries: ${groceries.tables} tables, stats: ${stats.tables} tables, ai: ${ai.tables} tables, app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
`health: ${health.tables} tables, tasks: ${tasks.tables} tables, groceries: ${groceries.tables} tables, stats: ${stats.tables} tables, ai: ${ai.tables} tables, movies: ${movies.tables} tables, app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
);
const [catalog] = await client`
select

200
scripts/movies-import.ts Normal file
View File

@@ -0,0 +1,200 @@
/**
* Import watch history into the movies & TV tracker (movies schema, /trackers/movies).
* Idempotent: re-running a file adds nothing new. Plays are keyed by source (source_key), titles
* by (type, tmdb_id) or normalised title + year, episodes by season + number (or name). Titles are
* deduped across sources by TMDB id: when TMDB_API_KEY is set, the import ends with a matching
* pass (scripts/movies-tmdb.ts) that merges rows resolving to the same TMDB title.
*
* On stan-ovh (DB_URL and the TMDB key are loaded for you, nothing printed):
* scripts/movies-run.sh import --netflix ~/imports/ViewingActivity.csv --profile-map "Stan=stan,Juhee=juhee"
* scripts/movies-run.sh import --netflix ~/imports/NetflixViewingHistory.csv --who both
* scripts/movies-run.sh import --letterboxd ~/imports/letterboxd-export.zip
* scripts/movies-run.sh import --plex ~/imports/tautulli-history.json --user-map "stan=stan,juhee=juhee"
* scripts/movies-run.sh import --json ~/imports/ju-movies.json
* Add --dry-run to parse and print a summary without touching the database.
*
* --netflix <csv>
* ViewingActivity.csv (netflix.com/account/getmyinfo → CONTENT_INTERACTION/ViewingActivity.csv):
* Profile Name,Start Time,Duration,Attributes,Title,Supplemental Video Type,Device Type,…
* Start Time is UTC ("2024-01-31 20:15:00"), Duration "HH:MM:SS". Trailers/hooks (any
* Supplemental Video Type) are skipped; sessions of the same title on the same Amsterdam day are
* summed into one play and dropped when under --min-minutes (default 5).
* --profile-map "Profile=stan|juhee|both,…" (unmapped profiles use --who), --profiles "Stan,Juhee"
* to import only those profiles.
* NetflixViewingHistory.csv (account → Viewing activity → Download all): Title,Date. Dates may be
* ISO, D-M-YYYY (Dutch site) or slash dates; D/M vs M/D is guessed from the file, else
* --date-order dmy|mdy (default dmy). All rows count for --who (default stan).
* Titles: "Show: Season 2: Episode" (also Seizoen/Part/Limited Series/…) become show episodes
* (season number + name; scripts/movies-tmdb.ts fills episode numbers from TMDB). A title whose
* first ": " segment appears with 3+ different endings in the file is a show too; everything
* else is a movie. Platform netflix.
*
* --letterboxd <zip|csv|dir>
* The Letterboxd export zip (Settings → Data → Export), its unzipped folder, or one of its CSVs.
* diary.csv: one play per entry on its Watched Date (rating from the entry); ratings.csv:
* rating; watched.csv: status watched (no play, its date is the logging date); watchlist.csv:
* status watchlist. All movies, who = --who (default stan), platform --platform (default none).
*
* --plex <json|csv> (assumptions: Plex has no official history export, so this reads)
* - Tautulli get_history JSON ({"response":{"data":{"data":[…]}}} or a plain array) or a CSV with
* the same column names: media_type (movie|episode; others skipped), title, year,
* grandparent_title (show), parent_media_index (season), media_index (episode), started or date
* (epoch seconds), play_duration or duration (seconds), friendly_name or user, percent_complete
* (plays under --min-percent, default 50, skipped), id/row_id (play id), rating_key, guids.
* - Plex Media Server /status/sessions/history/all as JSON ({"MediaContainer":{"Metadata":[…]}}):
* type, title, grandparentTitle, parentIndex, index, viewedAt, accountID, historyKey.
* A tmdb:// or themoviedb:// guid gives the TMDB id directly. --user-map "name=who,…" maps
* Plex/Tautulli users (unmapped → --who), --users "a,b" imports only those. Platform plex.
*
* --json <file> manual entries (Ju or Daisy). An array of:
* {
* "type": "movie" | "show", // "tv" is accepted as "show"
* "title": "Past Lives", "year": 2023, // year optional
* "tmdb_id": 666277, // optional; skips matching when given
* "status": "watched", // watching | watched | watchlist | dropped (optional)
* "rating": 4.5, // 0.5–5 in steps of 0.5 (optional)
* "rewatch_count": 1, "first_watched": "2023-09-01", "last_watched": "2024-02-11",
* "who": "both", // stan | juhee | both (default stan for plays)
* "platform": "cinema", // netflix | plex | cinema | other
* "notes": "…", "source": "ju", // "manual" (default) or "ju"
* "watched_on": ["2023-09-01"], // movie plays, one per date
* "episodes": [{ "season": 1, "episode": 3, "name": "…", "watched_on": "2024-01-02" }]
* }
* Fields given here overwrite the stored ones; omitted fields are left alone.
*
* Uses DB_URL (app role). TMDB_API_KEY is optional; without it everything imports unmatched
* (no posters) and a later `scripts/movies-run.sh tmdb` matches it. --no-tmdb skips the pass.
*/
import { readFile, readdir, stat } from 'node:fs/promises';
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import postgres from 'postgres';
import {
fromManual,
manualFile,
parseLetterboxd,
parseNetflix,
parsePlex,
parseWhoMap,
plexRows,
normTitle,
type ImportItem
} from '../src/lib/server/movies/parse';
import { importItems } from '../src/lib/server/movies/store';
import { Tmdb, runTmdb } from '../src/lib/server/movies/tmdb';
import {
MOVIE_PLATFORMS,
MOVIE_WHO,
type MoviePlatform,
type MovieWho
} from '../src/lib/server/db/schema/movies';
const argv = process.argv.slice(2);
const flag = (name: string) => argv.includes(`--${name}`);
const opt = (name: string) => {
const i = argv.indexOf(`--${name}`);
return i >= 0 ? argv[i + 1] : undefined;
};
const usage = () => {
console.error(
'usage: bun scripts/movies-import.ts (--netflix <csv> | --letterboxd <zip|csv|dir> | --plex <json|csv> | --json <file>) [--dry-run] [--no-tmdb] [--who stan|juhee|both] …'
);
process.exit(2);
};
const who = (opt('who') ?? 'stan') as MovieWho;
if (!MOVIE_WHO.includes(who)) usage();
const platform = opt('platform') as MoviePlatform | undefined;
if (platform && !MOVIE_PLATFORMS.includes(platform)) usage();
const list = (s?: string) =>
new Set(
(s ?? '')
.split(',')
.map((x) => normTitle(x))
.filter(Boolean)
);
let items: ImportItem[] = [];
let skipped = 0;
if (opt('netflix')) {
const order = opt('date-order');
if (order && order !== 'dmy' && order !== 'mdy') usage();
const r = parseNetflix(await readFile(opt('netflix')!, 'utf8'), {
who,
profiles: parseWhoMap(opt('profile-map')),
onlyProfiles: list(opt('profiles')),
dateOrder: order as 'dmy' | 'mdy' | undefined,
minMinutes: opt('min-minutes') ? Number(opt('min-minutes')) : undefined
});
items = r.items;
skipped = r.skipped;
} else if (opt('letterboxd')) {
const path = opt('letterboxd')!;
const files: Record<string, string> = {};
if ((await stat(path)).isDirectory()) {
for (const f of await readdir(path))
if (f.endsWith('.csv')) files[f] = await readFile(join(path, f), 'utf8');
} else if (path.toLowerCase().endsWith('.zip')) {
const names = execFileSync('unzip', ['-Z1', path], { encoding: 'utf8' })
.split('\n')
.filter((n) => /(^|\/)[a-z]+\.csv$/i.test(n) && !n.includes('deleted/'));
for (const n of names)
files[n] = execFileSync('unzip', ['-p', path, n], { encoding: 'utf8', maxBuffer: 64 << 20 });
} else files[path] = await readFile(path, 'utf8');
items = parseLetterboxd(files, { who, platform: platform ?? null });
} else if (opt('plex')) {
const path = opt('plex')!;
const r = parsePlex(
plexRows(await readFile(path, 'utf8'), path.toLowerCase().endsWith('.csv') ? 'csv' : 'json'),
{
who,
users: parseWhoMap(opt('user-map')),
onlyUsers: list(opt('users')),
minPercent: opt('min-percent') ? Number(opt('min-percent')) : undefined
}
);
items = r.items;
skipped = r.skipped;
} else if (opt('json')) {
const parsed = manualFile.safeParse(JSON.parse(await readFile(opt('json')!, 'utf8')));
if (!parsed.success) {
for (const issue of parsed.error.issues.slice(0, 50))
console.error(`${issue.path.join('.')}: ${issue.message}`);
process.exit(1);
}
items = parsed.data.map(fromManual);
} else usage();
const movies = items.filter((i) => i.type === 'movie').length;
const plays = items.reduce((n, i) => n + i.plays.length, 0);
console.log(
`Parsed ${items.length} titles (${movies} movies, ${items.length - movies} shows), ${plays} plays, ${skipped} rows skipped`
);
if (flag('dry-run')) {
for (const i of items.slice(0, 15))
console.log(
` ${i.type.padEnd(5)} ${i.title}${i.year ? ` (${i.year})` : ''}: ${i.plays.length} plays${i.episodes?.length ? `, ${i.episodes.length} episodes` : ''}`
);
process.exit(0);
}
if (!process.env.DB_URL)
throw new Error('DB_URL is required (use scripts/movies-run.sh on stan-ovh)');
const sql = postgres(process.env.DB_URL, { max: 1, onnotice: () => {} });
try {
const s = await importItems(sql, items);
console.log(
`Imported: ${s.titles} titles (${s.newTitles} new), ${s.newPlays} new plays of ${s.plays}, ${s.episodes} episodes seen`
);
if (process.env.TMDB_API_KEY && !flag('no-tmdb')) {
const m = await runTmdb(sql, new Tmdb(process.env.TMDB_API_KEY), {
log: (l) => console.log(` ${l}`)
});
console.log(
`TMDB: ${m.matched} matched, ${m.merged} merged, ${m.notFound} not found, ${m.refreshed} refreshed, ${m.errors} errors`
);
} else if (!process.env.TMDB_API_KEY)
console.log(
'No TMDB_API_KEY: titles stay unmatched (no posters) until scripts/movies-run.sh tmdb runs with a key'
);
} finally {
await sql.end();
}

View File

@@ -0,0 +1,47 @@
/**
* Import Ju's recommendations into movies.recs (the "Ju recommends" feed on /trackers/movies).
* Idempotent: upserts on (type, tmdb_id); title, for, reason and added_at are overwritten.
*
* scripts/movies-run.sh recs ~/imports/ju-recs.json # upsert
* scripts/movies-run.sh recs ~/imports/ju-recs.json --prune # also delete recs not in the file
* bun scripts/movies-recs-import.ts --dry-run ju-recs.json # validate only (no DB)
*
* JSON: an array of
* { "tmdb_id": 496243, "type": "movie", "title": "Parasite", "for": "both",
* "reason": "one line on why", "added_at": "2026-10-26T10:00:00+01:00" }
* type: movie | show ("tv" accepted); for: stan | juhee | both; reason and added_at optional
* (added_at defaults to now). Posters and year come from TMDB when TMDB_API_KEY is set.
*/
import { readFile } from 'node:fs/promises';
import postgres from 'postgres';
import { recsFile } from '../src/lib/server/movies/parse';
import { upsertRecs } from '../src/lib/server/movies/store';
import { Tmdb, runTmdb } from '../src/lib/server/movies/tmdb';
const args = process.argv.slice(2);
const files = args.filter((a) => !a.startsWith('--'));
if (files.length !== 1) {
console.error('usage: bun scripts/movies-recs-import.ts [--dry-run] [--prune] <file.json>');
process.exit(2);
}
const parsed = recsFile.safeParse(JSON.parse(await readFile(files[0], 'utf8')));
if (!parsed.success) {
for (const issue of parsed.error.issues.slice(0, 50))
console.error(`${issue.path.join('.')}: ${issue.message}`);
process.exit(1);
}
console.log(`Valid: ${parsed.data.length} recommendations`);
if (args.includes('--dry-run')) process.exit(0);
if (!process.env.DB_URL)
throw new Error('DB_URL is required (use scripts/movies-run.sh on stan-ovh)');
const sql = postgres(process.env.DB_URL, { max: 1, onnotice: () => {} });
try {
const r = await upsertRecs(sql, parsed.data, args.includes('--prune'));
console.log(`Upserted ${r.upserted} recommendations${r.pruned ? `, pruned ${r.pruned}` : ''}`);
if (process.env.TMDB_API_KEY) {
const m = await runTmdb(sql, new Tmdb(process.env.TMDB_API_KEY), { limit: 0 });
console.log(`TMDB: ${m.errors} errors`);
}
} finally {
await sql.end();
}

29
scripts/movies-run.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Run a movies tracker script on stan-ovh against prod, without printing secrets.
# scripts/movies-run.sh import --netflix ~/imports/ViewingActivity.csv [--dry-run] …
# scripts/movies-run.sh recs ~/imports/ju-recs.json [--prune]
# scripts/movies-run.sh tmdb [--retry-not-found]
# DB_URL (app role) comes from the running app container (db:5432 → 127.0.0.1:5440).
# TMDB_API_KEY comes from ~/.config/movies/tmdb.env (mode 600, one line TMDB_API_KEY=…), if present.
# Runs from this checkout; use a checkout of main (e.g. ~/dev/personal-website after a pull).
set -euo pipefail
cd "$(dirname "$0")/.."
cmd=${1:?usage: movies-run.sh import|recs|tmdb [args]}
shift
case "$cmd" in
import) script=scripts/movies-import.ts ;;
recs) script=scripts/movies-recs-import.ts ;;
tmdb) script=scripts/movies-tmdb.ts ;;
*) echo "unknown command $cmd (import, recs or tmdb)" >&2; exit 2 ;;
esac
DB_URL=$(sudo -n docker inspect personal-website-app-1 --format '{{range .Config.Env}}{{println .}}{{end}}' \
| sed -n 's/^DB_URL=//p' | sed 's/@db:5432/@127.0.0.1:5440/')
[ -n "$DB_URL" ] || { echo "no DB_URL from the app container" >&2; exit 1; }
export DB_URL
env_file="${MOVIES_ENV_FILE:-$HOME/.config/movies/tmdb.env}"
if [ -r "$env_file" ]; then
set -a
. "$env_file"
set +a
fi
exec bun "$script" "$@"

38
scripts/movies-tmdb.ts Normal file
View File

@@ -0,0 +1,38 @@
/**
* TMDB pass for the movies & TV tracker: match unmatched titles to TMDB ids, merge rows that turn
* out to be the same TMDB title, and cache posters, genres, year, runtime, cast and episode numbers.
* Needs TMDB_API_KEY (v3 key or v4 read token); scripts/movies-run.sh loads it from
* ~/.config/movies/tmdb.env. Without a key it exits 0 and changes nothing.
*
* scripts/movies-run.sh tmdb # pending titles + missing/old metadata
* scripts/movies-run.sh tmdb --retry-not-found # also retry titles TMDB didn't find before
* scripts/movies-run.sh tmdb --refresh-days 30 # refetch metadata older than 30 days (default 90)
*/
import postgres from 'postgres';
import { Tmdb, runTmdb } from '../src/lib/server/movies/tmdb';
const args = process.argv.slice(2);
const opt = (n: string) => {
const i = args.indexOf(`--${n}`);
return i >= 0 ? args[i + 1] : undefined;
};
if (!process.env.TMDB_API_KEY) {
console.log('No TMDB_API_KEY set: nothing to do');
process.exit(0);
}
if (!process.env.DB_URL)
throw new Error('DB_URL is required (use scripts/movies-run.sh on stan-ovh)');
const sql = postgres(process.env.DB_URL, { max: 1, onnotice: () => {} });
try {
const m = await runTmdb(sql, new Tmdb(process.env.TMDB_API_KEY), {
retryNotFound: args.includes('--retry-not-found'),
refreshDays: opt('refresh-days') ? Number(opt('refresh-days')) : undefined,
limit: opt('limit') ? Number(opt('limit')) : undefined,
log: (l) => console.log(` ${l}`)
});
console.log(
`TMDB: tried ${m.tried}, ${m.matched} matched, ${m.merged} merged, ${m.notFound} not found, ${m.refreshed} refreshed, ${m.errors} errors`
);
} finally {
await sql.end();
}

View File

@@ -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 = [

View File

@@ -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));

View File

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

View File

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

View File

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

View 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' }]);
});
});

View 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>;

View 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 };
}

View 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 };
}

View 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;
}

View File

@@ -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(/\/+$/, '');

View File

@@ -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',

View File

@@ -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()
});

View File

@@ -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)]"
/>

View File

@@ -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)}`,

View File

@@ -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' },

View File

@@ -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>

View 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)
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 273.42 35.52"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" y1="17.76" x2="273.42" y2="17.76" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset="0.56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><title>Asset 3</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M191.85,35.37h63.9A17.67,17.67,0,0,0,273.42,17.7h0A17.67,17.67,0,0,0,255.75,0h-63.9A17.67,17.67,0,0,0,174.18,17.7h0A17.67,17.67,0,0,0,191.85,35.37ZM10.1,35.42h7.8V6.92H28V0H0v6.9H10.1Zm28.1,0H46V8.25h.1L55.05,35.4h6L70.3,8.25h.1V35.4h7.8V0H66.45l-8.2,23.1h-.1L50,0H38.2ZM89.14.12h11.7a33.56,33.56,0,0,1,8.08,1,18.52,18.52,0,0,1,6.67,3.08,15.09,15.09,0,0,1,4.53,5.52,18.5,18.5,0,0,1,1.67,8.25,16.91,16.91,0,0,1-1.62,7.58,16.3,16.3,0,0,1-4.38,5.5,19.24,19.24,0,0,1-6.35,3.37,24.53,24.53,0,0,1-7.55,1.15H89.14Zm7.8,28.2h4a21.66,21.66,0,0,0,5-.55A10.58,10.58,0,0,0,110,26a8.73,8.73,0,0,0,2.68-3.35,11.9,11.9,0,0,0,1-5.08,9.87,9.87,0,0,0-1-4.52,9.17,9.17,0,0,0-2.63-3.18A11.61,11.61,0,0,0,106.22,8a17.06,17.06,0,0,0-4.68-.63h-4.6ZM133.09.12h13.2a32.87,32.87,0,0,1,4.63.33,12.66,12.66,0,0,1,4.17,1.3,7.94,7.94,0,0,1,3,2.72,8.34,8.34,0,0,1,1.15,4.65,7.48,7.48,0,0,1-1.67,5,9.13,9.13,0,0,1-4.43,2.82V17a10.28,10.28,0,0,1,3.18,1,8.51,8.51,0,0,1,2.45,1.85,7.79,7.79,0,0,1,1.57,2.62,9.16,9.16,0,0,1,.55,3.2,8.52,8.52,0,0,1-1.2,4.68,9.32,9.32,0,0,1-3.1,3A13.38,13.38,0,0,1,152.32,35a22.5,22.5,0,0,1-4.73.5h-14.5Zm7.8,14.15h5.65a7.65,7.65,0,0,0,1.78-.2,4.78,4.78,0,0,0,1.57-.65,3.43,3.43,0,0,0,1.13-1.2,3.63,3.63,0,0,0,.42-1.8A3.3,3.3,0,0,0,151,8.6a3.42,3.42,0,0,0-1.23-1.13A6.07,6.07,0,0,0,148,6.9a9.9,9.9,0,0,0-1.85-.18h-5.3Zm0,14.65h7a8.27,8.27,0,0,0,1.83-.2,4.67,4.67,0,0,0,1.67-.7,3.93,3.93,0,0,0,1.23-1.3,3.8,3.8,0,0,0,.47-1.95,3.16,3.16,0,0,0-.62-2,4,4,0,0,0-1.58-1.18,8.23,8.23,0,0,0-2-.55,15.12,15.12,0,0,0-2.05-.15h-5.9Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB