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