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

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

View File

@@ -1,6 +1,7 @@
import { isAdmin } from '#lib/server/authorization.js';
import { isOwner } from '#lib/server/owner.js';
import { loadInactiveGames } from '#lib/server/games/active.js';
import { loadHiddenTrackers } from '#lib/server/movies/site.js';
import { dev } from '$app/env';
import { siteEnvironment } from '#lib/title.js';
import type { LayoutServerLoad } from './$types';
@@ -15,5 +16,7 @@ export const load: LayoutServerLoad = async (event) => ({
version: process.env.APP_VERSION || 'dev',
environment,
/** Games I don't play now: navigation lists them under "non-active" (#lib/projects). */
inactiveGames: await loadInactiveGames()
inactiveGames: await loadInactiveGames(),
/** Trackers switched to private by their owner setting (movies): hidden from everyone else. */
hiddenTrackers: await loadHiddenTrackers()
});

View File

@@ -138,7 +138,7 @@
>
</h2>
<CatalogGrid
items={visibleTrackers(data.isOwner, data.inactiveGames)}
items={visibleTrackers(data.isOwner, data.inactiveGames, data.hiddenTrackers)}
variant="tracker"
subtitleClass="mt-1 block text-base text-[var(--site-muted)]"
/>

View File

@@ -6,6 +6,7 @@ import { races } from '#lib/derby/races.js';
import { COURSES } from '#lib/language-learner/courses/index.js';
import { gameTrackers } from '#lib/projects.js';
import { and, eq, isNull, ne } from 'drizzle-orm';
import { getVisibility } from '#lib/server/movies/site.js';
import type { RequestHandler } from './$types';
const staticPaths = [
@@ -41,6 +42,7 @@ const staticPaths = [
'/trackers/wildlife',
'/trackers/wildlife/groups',
'/trackers/music',
'/trackers/movies',
'/trackers/pc',
'/trackers/games',
...gameTrackers.map((g) => g.href),
@@ -68,7 +70,7 @@ function escapeXml(value: string): string {
}
export const GET: RequestHandler = async () => {
const [articles, lists, books] = await Promise.all([
const [articles, lists, books, moviesVisibility] = await Promise.all([
db
.select({ slug: blogArticles.slug, updatedAt: blogArticles.updatedAt })
.from(blogArticles)
@@ -78,10 +80,13 @@ export const GET: RequestHandler = async () => {
.select({ id: bookWorks.id, updatedAt: bookWorks.updatedAt })
.from(bookWorks)
// Articles are links to other sites; their pages here are thin.
.where(and(ne(bookWorks.kind, 'article'), isNull(bookWorks.archivedAt)))
.where(and(ne(bookWorks.kind, 'article'), isNull(bookWorks.archivedAt))),
getVisibility()
]);
const entries: { path: string; lastmod?: string }[] = [
...staticPaths.map((path) => ({ path })),
...staticPaths
.filter((path) => moviesVisibility === 'public' || path !== '/trackers/movies')
.map((path) => ({ path })),
...races.map((race) => ({ path: `/projects/derby/${race.slug}` })),
...articles.map((article) => ({
path: `/blog/${encodeURIComponent(article.slug)}`,

View File

@@ -41,7 +41,7 @@
// Same list, order and icons as the header Trackers dropdown (owner-only ones only when
// the header shows them). Spotting is a group tile with one-click chips per sub-tracker.
const trackers = $derived(visibleTrackers(data.isOwner, data.inactiveGames));
const trackers = $derived(visibleTrackers(data.isOwner, data.inactiveGames, data.hiddenTrackers));
// Search-only shortcuts that aren't trackers of their own.
const extraHits = [
{ name: 'Weigh-in', href: '/trackers/health#body' },

View File

@@ -10,5 +10,9 @@
<p class="mb-10 text-[var(--site-muted)]">
Personal data I keep track of: games, money, houses, books, prices, animals and lists.
</p>
<CatalogGrid items={visibleTrackers(data.isOwner, data.inactiveGames)} variant="tracker" wide />
<CatalogGrid
items={visibleTrackers(data.isOwner, data.inactiveGames, data.hiddenTrackers)}
variant="tracker"
wide
/>
</div>

View File

@@ -0,0 +1,52 @@
import { error, fail } from '@sveltejs/kit';
import { isOwner, requireOwner } from '#lib/server/owner.js';
import {
addTitle,
deleteRec,
deleteTitle,
getVisibility,
loadRecs,
loadStats,
loadTitles,
setVisibility,
updateTitle
} from '#lib/server/movies/site.js';
import type { Actions, PageServerLoad } from './$types';
// Movies & TV: public by default; the owner's visibility switch can make it owner-only.
export const load: PageServerLoad = async (event) => {
const owner = isOwner(event);
const visibility = await getVisibility();
if (visibility === 'private' && !owner) error(404, 'Not found');
if (owner) event.setHeaders({ 'cache-control': 'private, no-store' });
const [titles, stats, recs] = await Promise.all([loadTitles(), loadStats(), loadRecs()]);
return {
owner,
visibility,
titles,
stats,
recs,
seo: {
title: 'Movies & TV tracker',
description:
'The movies and TV shows we watch: what we are watching, ratings, rewatches and stats per month, genre and platform.'
}
};
};
type Result = { ok: true; id?: number } | { ok: false; message: string };
const run =
(fn: (form: FormData) => Promise<Result>) => async (event: Parameters<Actions[string]>[0]) => {
requireOwner(event);
const res = await fn(await event.request.formData());
if (!res.ok) return fail(400, { message: res.message });
return res;
};
export const actions: Actions = {
update: run(updateTitle),
add: run(addTitle),
delete: run(deleteTitle),
deleteRec: run(deleteRec),
visibility: run(setVisibility)
};

File diff suppressed because it is too large Load Diff