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