Add the profile page, and publish it as an embeddable panel
Developed in a separate session; committed here alongside the calendar work
that shares this repository's migration journal.
A person can now see their own Plex link, their Watch Now slots and their watch
history at /profile. The same panel is published in two further forms so that
accounts.sticknife.com on charon can carry it as one section of a wider
sticknife profile, next to the other services' sections.
- watch_history (0024) records what has been played, keyed on the Plex
history id so a re-sync cannot duplicate a row. Partial unique index,
because that id is null for anything entered by hand.
- plex_accounts.is_server_owner (0025) marks the one account whose viewing
the server files under local account 1 rather than under its plex.tv id.
- The embed carries its own layout, origin allowlist and a frame-height
reporter, so the host page can size it without guessing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -153,6 +153,21 @@ function imageUrl(path?: string | null) {
|
||||
return path ? `${TMDB_IMAGE_BASE_URL}${path}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A usable poster URL from whatever media_items.poster_path happens to hold.
|
||||
*
|
||||
* Two writers disagree about that column. The metadata refresh stores TMDB's
|
||||
* bare path (`/abc.jpg`, 2,802 rows) and the catalog board stores the absolute
|
||||
* URL it had already built (1 row). Both forms are in the table right now, so
|
||||
* anything rendering a stored poster has to cope with both -- and anything
|
||||
* handing one to ANOTHER SERVICE has to resolve it, because a bare TMDB path
|
||||
* means nothing on accounts.sticknife.com.
|
||||
*/
|
||||
export function posterUrlFromStored(path?: string | null) {
|
||||
if (!path) return null;
|
||||
return /^https?:\/\//.test(path) ? path : imageUrl(path);
|
||||
}
|
||||
|
||||
function backdropUrl(path?: string | null) {
|
||||
return path ? `${TMDB_BACKDROP_BASE_URL}${path}` : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* The origins allowed to embed Ampelos or read its profile API from a browser.
|
||||
*
|
||||
* AMPELOS_EMBED_ANCESTORS is the single source of truth for both, and it is
|
||||
* read in two places on purpose: next.config.ts needs it at build time to write
|
||||
* the CSP `frame-ancestors` header, and route handlers need it at request time
|
||||
* to answer CORS. Keeping one env var and two readers is better than a shared
|
||||
* module, because next.config.ts cannot use the "@/" path alias.
|
||||
*
|
||||
* Origins, not hostnames -- scheme included. Both the CSP parser and the CORS
|
||||
* Origin header deal in origins, and a bare hostname is silently ignored by the
|
||||
* first and never matches the second.
|
||||
*/
|
||||
const DEFAULT_ORIGINS = "https://accounts.sticknife.com";
|
||||
|
||||
export function embedOrigins(): string[] {
|
||||
return (process.env.AMPELOS_EMBED_ANCESTORS ?? DEFAULT_ORIGINS)
|
||||
.split(",")
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function isAllowedOrigin(origin: string | null): origin is string {
|
||||
return Boolean(origin) && embedOrigins().includes(origin!);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Finishing a Plex link: the part that touches the database.
|
||||
//
|
||||
// Deliberately knows nothing about cookies, redirects or revalidation. It used
|
||||
// to run inside the render of the profile page, which is why linking never
|
||||
// completed: a page render may not delete a cookie or call revalidatePath, so
|
||||
// the moment Plex returned the user, the page threw. The caller is now a route
|
||||
// handler, which is allowed to do both -- see src/app/api/plex/callback/route.ts.
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
import { db } from "@/db/client";
|
||||
import { plexAccounts } from "@/db/schema";
|
||||
import { claimPin, ownerAccountId, shareLibraries } from "@/lib/plex";
|
||||
|
||||
/**
|
||||
* Where the in-flight PIN id is parked between starting a link and finishing
|
||||
* it. Lives here rather than in the route handler because a route.ts may only
|
||||
* export handlers and route config -- exporting a constant from one trips
|
||||
* Next's type check on the module.
|
||||
*/
|
||||
export const PIN_COOKIE = "ampelos_plex_pin";
|
||||
|
||||
/**
|
||||
* Outcome codes. These travel back to the profile page in the query string, so
|
||||
* they are short, stable, and carry no detail that would be wrong to put in a
|
||||
* URL somebody might paste.
|
||||
*/
|
||||
export type PlexLinkOutcome =
|
||||
| "linked"
|
||||
| "owner"
|
||||
| "already-shared"
|
||||
| "share-failed"
|
||||
| "not-completed"
|
||||
| "taken"
|
||||
| "error";
|
||||
|
||||
export type PlexLinkResult = {
|
||||
outcome: PlexLinkOutcome;
|
||||
username?: string;
|
||||
/** Only for the failure cases, and only ever a short reason. */
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export async function completePlexLink(userId: string, pinId: number): Promise<PlexLinkResult> {
|
||||
let identity;
|
||||
try {
|
||||
identity = await claimPin(pinId);
|
||||
} catch (error) {
|
||||
return { outcome: "error", reason: (error as Error).message };
|
||||
}
|
||||
|
||||
// An abandoned sign-in and an expired PIN are both things a person does, not
|
||||
// errors: the PIN is simply still unclaimed.
|
||||
if (!identity) return { outcome: "not-completed" };
|
||||
|
||||
// One Plex account per person, in both directions. Without this a second
|
||||
// person could link an account already in use and inherit its watchlist.
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(plexAccounts)
|
||||
.where(eq(plexAccounts.plexUserId, identity.plexUserId))
|
||||
.limit(1);
|
||||
if (existing.length && existing[0].userId !== userId) {
|
||||
return { outcome: "taken", username: identity.username };
|
||||
}
|
||||
|
||||
// THE OWNER IS NOT INVITED TO THEIR OWN SERVER. Plex answers a share request
|
||||
// naming them with "You cannot send an invitation to yourself", and reporting
|
||||
// that as a failed share would tell the one person who definitely has access
|
||||
// that they have none, on every visit, forever.
|
||||
//
|
||||
// Checked before attempting the share rather than by matching that sentence
|
||||
// afterwards: the wording belongs to Plex and may change, and a share request
|
||||
// that was never going to succeed is not worth making.
|
||||
let isOwner = false;
|
||||
try {
|
||||
const owner = await ownerAccountId();
|
||||
isOwner = owner !== null && owner === identity.plexUserId;
|
||||
} catch {
|
||||
// Unreachable plex.tv means we cannot tell. Falling through to the share
|
||||
// attempt is the safe direction: a non-owner still gets their invitation,
|
||||
// and an owner gets the old confusing message rather than a wrong claim of
|
||||
// access.
|
||||
}
|
||||
|
||||
let shared: { sectionTitles: string[]; sectionIds: string[]; alreadyShared: boolean } | null = null;
|
||||
let shareError: string | null = null;
|
||||
if (!isOwner) {
|
||||
try {
|
||||
shared = await shareLibraries(identity);
|
||||
} catch (error) {
|
||||
// The link is still worth recording: it is what makes the watchlist and
|
||||
// the watch history readable, and a share that failed can be retried
|
||||
// without signing in again.
|
||||
shareError = (error as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
const values = {
|
||||
plexUserId: identity.plexUserId,
|
||||
plexUuid: identity.plexUuid,
|
||||
plexUsername: identity.username,
|
||||
plexEmail: identity.email,
|
||||
isServerOwner: isOwner,
|
||||
// Both stay null for the owner: nothing was shared with them because
|
||||
// nothing needed to be.
|
||||
librariesSharedAt: shared ? new Date() : null,
|
||||
sharedSectionIds: shared?.sectionIds ?? null,
|
||||
};
|
||||
|
||||
await db
|
||||
.insert(plexAccounts)
|
||||
.values({ userId, ...values })
|
||||
.onConflictDoUpdate({
|
||||
target: plexAccounts.userId,
|
||||
set: { ...values, updatedAt: new Date() },
|
||||
});
|
||||
|
||||
if (isOwner) return { outcome: "owner", username: identity.username };
|
||||
if (shareError) return { outcome: "share-failed", username: identity.username, reason: shareError };
|
||||
if (shared?.alreadyShared) return { outcome: "already-shared", username: identity.username };
|
||||
return { outcome: "linked", username: identity.username };
|
||||
}
|
||||
+56
-2
@@ -11,6 +11,8 @@
|
||||
// Only the owner's token is ever used. Linking a user proves who they are and
|
||||
// then discards their token; see plexAccounts in the schema for why.
|
||||
|
||||
import { APP_URL } from "@/lib/app-url";
|
||||
|
||||
const PLEX_TOKEN = process.env.PLEX_AUTH_TOKEN ?? "";
|
||||
const MACHINE_ID = process.env.PLEX_MACHINE_IDENTIFIER ?? "";
|
||||
|
||||
@@ -91,13 +93,30 @@ export function isShareable(section: PlexSection) {
|
||||
|
||||
export type PlexPin = { id: number; code: string };
|
||||
|
||||
/** Start a sign-in. The user takes the code to plex.tv; we poll for the result. */
|
||||
/**
|
||||
* Start a sign-in. The user takes the code to plex.tv; we poll for the result.
|
||||
*
|
||||
* THE ORIGIN HEADER IS LOAD-BEARING AND IS WHY THIS USED TO FAIL.
|
||||
*
|
||||
* Plex records an `origin` against the PIN, taken from the Origin header on
|
||||
* this request, and the sign-in page at app.plex.tv reads it back through
|
||||
* /api/v2/pins/info before it will honour `forwardUrl`. A PIN with a null
|
||||
* origin gets the user signed in and then stranded on plex.tv instead of
|
||||
* returned here.
|
||||
*
|
||||
* Everything else creates its PIN from the BROWSER, where the header is sent
|
||||
* automatically and nobody has to know this. Ampelos creates it in a server
|
||||
* action, where fetch sends no Origin at all -- so it has to be stated.
|
||||
* Measured against the live API: without it origin is null, with it origin is
|
||||
* "ampelos.sticknife.com".
|
||||
*/
|
||||
export async function createPin(): Promise<PlexPin> {
|
||||
const response = await fetch("https://plex.tv/api/v2/pins?strong=true", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER,
|
||||
"X-Plex-Product": "Ampelos",
|
||||
Origin: new URL(APP_URL).origin,
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal: AbortSignal.timeout(20000),
|
||||
@@ -163,8 +182,43 @@ export async function claimPin(pinId: number): Promise<PlexIdentity | null> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Who owns this server, according to the token Ampelos holds.
|
||||
*
|
||||
* Needed because the owner cannot be invited to their own libraries -- Plex
|
||||
* answers a share request naming them with HTTP 400 "You cannot send an
|
||||
* invitation to yourself." That is not a failure to handle, it is a state to
|
||||
* recognise: the owner already has every library, so there is nothing to grant.
|
||||
*
|
||||
* Asked rather than pattern-matched on that error text, because the wording is
|
||||
* Plex's to change and being wrong here would mean telling the owner their
|
||||
* libraries had failed to share forever.
|
||||
*/
|
||||
export async function ownerAccountId(): Promise<string | null> {
|
||||
const response = await fetch("https://plex.tv/api/v2/user", {
|
||||
headers: ownerHeaders(),
|
||||
signal: AbortSignal.timeout(20000),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const account = await response.json();
|
||||
return account?.id != null ? String(account.id) : null;
|
||||
}
|
||||
|
||||
// --- sharing ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The human-readable half of a Plex error.
|
||||
*
|
||||
* Plex answers failures with XML whose only useful content is the `status`
|
||||
* attribute. Surfacing the raw document instead put `<?xml version="1.0"...`
|
||||
* in front of the sentence and pushed the sentence itself past the point where
|
||||
* the message got truncated -- which is how "You cannot send an invitation to
|
||||
* yourself." reached a person as "You cannot send a".
|
||||
*/
|
||||
function plexErrorText(body: string) {
|
||||
return body.match(/status="([^"]*)"/)?.[1] ?? body.trim().slice(0, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite an account to the shareable libraries.
|
||||
*
|
||||
@@ -197,7 +251,7 @@ export async function shareLibraries(identity: { email: string | null; plexUserI
|
||||
const text = await response.text();
|
||||
const alreadyShared = /already/i.test(text) && /shar/i.test(text);
|
||||
if (!response.ok && !alreadyShared) {
|
||||
throw new Error(`Plex refused the share (HTTP ${response.status}): ${text.slice(0, 200)}`);
|
||||
throw new Error(`Plex refused the share: ${plexErrorText(text)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
// Everything Ampelos knows about one person, in one shape.
|
||||
//
|
||||
// This exists because the same profile is rendered three ways -- the page at
|
||||
// /profile, the chrome-less fragment charon iframes at /embed/profile, and the
|
||||
// JSON at /api/profile -- and three readers of the same tables would drift.
|
||||
// They have drifted before: the catalog board and the placement classifier each
|
||||
// decided for themselves what "purged" meant and disagreed about which titles
|
||||
// were.
|
||||
//
|
||||
// PROFILE_SCHEMA_VERSION is part of the contract with accounts.sticknife.com.
|
||||
// Charon renders a panel it does not own from a service it cannot redeploy in
|
||||
// step, so it needs to be able to say "I understand version 1" and degrade
|
||||
// rather than break. Bump it when a field's MEANING changes; adding a field
|
||||
// does not need a bump, because a consumer ignoring an unknown key is fine.
|
||||
|
||||
import { and, desc, eq, isNull, sql } from "drizzle-orm";
|
||||
|
||||
import { db } from "@/db/client";
|
||||
import {
|
||||
episodes,
|
||||
externalIds,
|
||||
mediaItems,
|
||||
plexAccounts,
|
||||
seasons,
|
||||
users,
|
||||
watchHistory,
|
||||
watchingNowItems,
|
||||
} from "@/db/schema";
|
||||
import { posterUrlFromStored } from "@/lib/catalog";
|
||||
|
||||
// 2: history.recent is one row per EPISODE rather than per play, and its
|
||||
// watchedAt is the most recent play of that episode. A consumer written
|
||||
// against 1 would double-count. Adding playCount, distinctEpisodes and the
|
||||
// catalog refs alone would not have earned a bump; changing what a row
|
||||
// MEANS does.
|
||||
export const PROFILE_SCHEMA_VERSION = 2;
|
||||
|
||||
/** How many plays the panel shows. The full history is not a profile panel's job. */
|
||||
const RECENT_HISTORY_LIMIT = 25;
|
||||
|
||||
export type ProfileMediaType = "tv_series" | "movie";
|
||||
|
||||
/**
|
||||
* What the catalog needs to open its detail panel for a title.
|
||||
*
|
||||
* The board deals in TMDB ids, not media_items ids, so a profile entry cannot
|
||||
* be opened without one. Null when the title has no TMDB link, or has one a
|
||||
* reviewer has REJECTED -- a rejected id is a known-wrong id kept as evidence,
|
||||
* and following it would open somebody else's film.
|
||||
*/
|
||||
export type CatalogRef = {
|
||||
tmdbId: string;
|
||||
kind: "television" | "movies";
|
||||
} | null;
|
||||
|
||||
export type WatchNowEntry = {
|
||||
id: string;
|
||||
mediaItemId: string;
|
||||
title: string;
|
||||
year: number | null;
|
||||
posterUrl: string | null;
|
||||
slotNumber: number | null;
|
||||
addedAt: string;
|
||||
catalog: CatalogRef;
|
||||
};
|
||||
|
||||
export type WatchNowList = {
|
||||
quota: number;
|
||||
used: number;
|
||||
items: WatchNowEntry[];
|
||||
};
|
||||
|
||||
export type HistoryEntry = {
|
||||
id: string;
|
||||
mediaItemId: string;
|
||||
mediaType: ProfileMediaType;
|
||||
title: string;
|
||||
year: number | null;
|
||||
posterUrl: string | null;
|
||||
/** Null for a movie. */
|
||||
seasonNumber: number | null;
|
||||
episodeNumber: number | null;
|
||||
/** The episode's own title, when this database has a row for it. */
|
||||
episodeTitle: string | null;
|
||||
/** The MOST RECENT time this episode was played; see getWatchHistory. */
|
||||
watchedAt: string;
|
||||
/** How many times in total, so collapsing repeats does not hide them. */
|
||||
playCount: number;
|
||||
source: "plex" | "manual";
|
||||
catalog: CatalogRef;
|
||||
};
|
||||
|
||||
export type PlexLink =
|
||||
| { linked: false }
|
||||
| {
|
||||
linked: true;
|
||||
username: string;
|
||||
email: string | null;
|
||||
linkedAt: string;
|
||||
/** This account owns the Plex server, so it was never invited to anything. */
|
||||
isServerOwner: boolean;
|
||||
librariesSharedAt: string | null;
|
||||
sharedLibraryCount: number;
|
||||
};
|
||||
|
||||
export type Profile = {
|
||||
service: "ampelos";
|
||||
schemaVersion: number;
|
||||
generatedAt: string;
|
||||
user: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
};
|
||||
plex: PlexLink;
|
||||
watchNow: {
|
||||
television: WatchNowList;
|
||||
movies: WatchNowList;
|
||||
};
|
||||
history: {
|
||||
/** Every play on record, counting repeats. */
|
||||
total: number;
|
||||
/** Distinct episodes and films -- what `recent` is a window onto. */
|
||||
distinctEpisodes: number;
|
||||
playsLast30Days: number;
|
||||
lastWatchedAt: string | null;
|
||||
recent: HistoryEntry[];
|
||||
};
|
||||
/** Absolute, so charon can link back into Ampelos without knowing its address. */
|
||||
links: {
|
||||
self: string;
|
||||
embed: string;
|
||||
api: string;
|
||||
};
|
||||
};
|
||||
|
||||
function iso(value: Date | string | null | undefined) {
|
||||
if (!value) return null;
|
||||
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
||||
}
|
||||
|
||||
async function getPlexLink(userId: string): Promise<PlexLink> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(plexAccounts)
|
||||
.where(eq(plexAccounts.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!row) return { linked: false };
|
||||
|
||||
return {
|
||||
linked: true,
|
||||
username: row.plexUsername,
|
||||
email: row.plexEmail,
|
||||
linkedAt: iso(row.linkedAt)!,
|
||||
isServerOwner: row.isServerOwner,
|
||||
librariesSharedAt: iso(row.librariesSharedAt),
|
||||
sharedLibraryCount: row.sharedSectionIds?.length ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The TMDB link to join through, when there is a trustworthy one.
|
||||
*
|
||||
* `rejected_at is null` is the load-bearing half: external_ids deliberately
|
||||
* KEEPS an id a reviewer has rejected, as the evidence of what went wrong. It
|
||||
* is a known-wrong id, so a link built on it would open the wrong title.
|
||||
*/
|
||||
const tmdbJoin = and(
|
||||
eq(externalIds.source, "tmdb"),
|
||||
isNull(externalIds.rejectedAt),
|
||||
);
|
||||
|
||||
function catalogRef(tmdbId: string | null, mediaType: ProfileMediaType): CatalogRef {
|
||||
if (!tmdbId) return null;
|
||||
return { tmdbId, kind: mediaType === "movie" ? "movies" : "television" };
|
||||
}
|
||||
|
||||
async function getWatchNow(userId: string, mediaType: ProfileMediaType, quota: number): Promise<WatchNowList> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: watchingNowItems.id,
|
||||
mediaItemId: mediaItems.id,
|
||||
title: mediaItems.title,
|
||||
year: mediaItems.year,
|
||||
posterPath: mediaItems.posterPath,
|
||||
slotNumber: watchingNowItems.slotNumber,
|
||||
addedAt: watchingNowItems.addedAt,
|
||||
tmdbId: externalIds.externalId,
|
||||
})
|
||||
.from(watchingNowItems)
|
||||
.innerJoin(mediaItems, eq(mediaItems.id, watchingNowItems.mediaItemId))
|
||||
.leftJoin(externalIds, and(eq(externalIds.mediaItemId, mediaItems.id), tmdbJoin))
|
||||
.where(
|
||||
and(
|
||||
eq(watchingNowItems.userId, userId),
|
||||
isNull(watchingNowItems.removedAt),
|
||||
eq(mediaItems.mediaType, mediaType),
|
||||
),
|
||||
)
|
||||
// Slot order, with the unslotted last rather than first: a null sorts high
|
||||
// in Postgres by default and would put the least-placed item at the top.
|
||||
.orderBy(sql`${watchingNowItems.slotNumber} asc nulls last`);
|
||||
|
||||
return {
|
||||
quota,
|
||||
used: rows.length,
|
||||
items: rows.map((row) => ({
|
||||
id: row.id,
|
||||
mediaItemId: row.mediaItemId,
|
||||
title: row.title,
|
||||
year: row.year,
|
||||
posterUrl: posterUrlFromStored(row.posterPath),
|
||||
slotNumber: row.slotNumber,
|
||||
addedAt: iso(row.addedAt)!,
|
||||
catalog: catalogRef(row.tmdbId, mediaType),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* This person's plays, and only ever this person's.
|
||||
*
|
||||
* The userId filter is the whole privacy guarantee for the feature. Plex hands
|
||||
* the owner's token everybody's history, so the restraint has to live here --
|
||||
* there is no separate credential that would have failed if this were widened
|
||||
* by accident. Nothing in this file takes an "all users" mode, deliberately:
|
||||
* an admin view of who watched what would be a surveillance surface over the
|
||||
* people the server is shared with, and it is not one anybody asked for.
|
||||
*/
|
||||
async function getWatchHistory(userId: string) {
|
||||
// ONE ROW PER EPISODE, NOT PER PLAY.
|
||||
//
|
||||
// A rewatch, a resume after stopping, and a client that reports the same
|
||||
// episode twice all land as separate plays -- so the raw table shows the same
|
||||
// SVU episode filling the panel and buries everything else. Collapsing to the
|
||||
// most recent play is what makes this readable as "what have I been
|
||||
// watching".
|
||||
//
|
||||
// `distinct on` rather than a group-by because it keeps the whole winning row
|
||||
// (the id, the poster, the exact timestamp) without listing every column
|
||||
// twice. Its ORDER BY must lead with the distinct expressions, which is why
|
||||
// the ordering people actually see is applied in the outer query below.
|
||||
//
|
||||
// NULLs are treated as equal here, unlike in a unique index -- which is what
|
||||
// makes a movie (null season, null episode) collapse to one row correctly.
|
||||
const deduped = db
|
||||
.selectDistinctOn([watchHistory.mediaItemId, watchHistory.seasonNumber, watchHistory.episodeNumber], {
|
||||
id: watchHistory.id,
|
||||
mediaItemId: watchHistory.mediaItemId,
|
||||
seasonNumber: watchHistory.seasonNumber,
|
||||
episodeNumber: watchHistory.episodeNumber,
|
||||
watchedAt: watchHistory.watchedAt,
|
||||
source: watchHistory.source,
|
||||
// Counted before the dedup throws the repeats away, so "watched 3 times"
|
||||
// survives showing only one of them.
|
||||
playCount: sql<number>`count(*) over (
|
||||
partition by ${watchHistory.mediaItemId}, ${watchHistory.seasonNumber}, ${watchHistory.episodeNumber}
|
||||
)::int`.as("play_count"),
|
||||
})
|
||||
.from(watchHistory)
|
||||
.where(eq(watchHistory.userId, userId))
|
||||
.orderBy(
|
||||
watchHistory.mediaItemId,
|
||||
watchHistory.seasonNumber,
|
||||
watchHistory.episodeNumber,
|
||||
desc(watchHistory.watchedAt),
|
||||
)
|
||||
.as("deduped");
|
||||
|
||||
// Episode titles come from a left join through seasons, because a play can
|
||||
// name a season and episode this database has no row for -- see the schema
|
||||
// comment on watch_history.season_number.
|
||||
const recent = await db
|
||||
.select({
|
||||
id: deduped.id,
|
||||
mediaItemId: mediaItems.id,
|
||||
mediaType: mediaItems.mediaType,
|
||||
title: mediaItems.title,
|
||||
year: mediaItems.year,
|
||||
posterPath: mediaItems.posterPath,
|
||||
seasonNumber: deduped.seasonNumber,
|
||||
episodeNumber: deduped.episodeNumber,
|
||||
episodeTitle: episodes.title,
|
||||
watchedAt: deduped.watchedAt,
|
||||
playCount: deduped.playCount,
|
||||
source: deduped.source,
|
||||
tmdbId: externalIds.externalId,
|
||||
})
|
||||
.from(deduped)
|
||||
.innerJoin(mediaItems, eq(mediaItems.id, deduped.mediaItemId))
|
||||
.leftJoin(externalIds, and(eq(externalIds.mediaItemId, mediaItems.id), tmdbJoin))
|
||||
.leftJoin(
|
||||
seasons,
|
||||
and(
|
||||
eq(seasons.seriesId, deduped.mediaItemId),
|
||||
eq(seasons.seasonNumber, deduped.seasonNumber),
|
||||
),
|
||||
)
|
||||
.leftJoin(
|
||||
episodes,
|
||||
and(
|
||||
eq(episodes.seasonId, seasons.id),
|
||||
eq(episodes.episodeNumber, deduped.episodeNumber),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(deduped.watchedAt))
|
||||
.limit(RECENT_HISTORY_LIMIT);
|
||||
|
||||
// watched_at is `timestamp without time zone` holding UTC, which is the
|
||||
// house-wide fragility recorded in PLANNING.md. Comparing it against now()
|
||||
// directly would be wrong by the server's Europe/Stockholm offset, so the
|
||||
// cutoff is computed in UTC on both sides.
|
||||
const [totals] = await db
|
||||
.select({
|
||||
total: sql<number>`count(*)::int`,
|
||||
// The number the deduped list is a window onto. Counting plays there
|
||||
// instead would promise more rows than the list can ever show.
|
||||
distinctEpisodes: sql<number>`count(distinct (
|
||||
${watchHistory.mediaItemId}, ${watchHistory.seasonNumber}, ${watchHistory.episodeNumber}
|
||||
))::int`,
|
||||
last30: sql<number>`count(*) filter (
|
||||
where ${watchHistory.watchedAt} >= (now() at time zone 'utc') - interval '30 days'
|
||||
)::int`,
|
||||
lastWatchedAt: sql<Date | null>`max(${watchHistory.watchedAt})`,
|
||||
})
|
||||
.from(watchHistory)
|
||||
.where(eq(watchHistory.userId, userId));
|
||||
|
||||
return {
|
||||
total: totals?.total ?? 0,
|
||||
distinctEpisodes: totals?.distinctEpisodes ?? 0,
|
||||
playsLast30Days: totals?.last30 ?? 0,
|
||||
lastWatchedAt: iso(totals?.lastWatchedAt),
|
||||
recent: recent.map((row) => ({
|
||||
id: row.id,
|
||||
mediaItemId: row.mediaItemId,
|
||||
mediaType: row.mediaType,
|
||||
title: row.title,
|
||||
year: row.year,
|
||||
posterUrl: posterUrlFromStored(row.posterPath),
|
||||
seasonNumber: row.seasonNumber,
|
||||
episodeNumber: row.episodeNumber,
|
||||
episodeTitle: row.episodeTitle,
|
||||
watchedAt: iso(row.watchedAt)!,
|
||||
playCount: row.playCount,
|
||||
source: row.source,
|
||||
catalog: catalogRef(row.tmdbId, row.mediaType),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the profile.
|
||||
*
|
||||
* Every read is scoped to `userId` and there is no parameter that widens that.
|
||||
* Callers are responsible for proving who the user is; see the route handlers.
|
||||
*/
|
||||
export async function getProfile(userId: string, baseUrl: string): Promise<Profile | null> {
|
||||
const [user] = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
displayName: users.displayName,
|
||||
email: users.email,
|
||||
tvSlots: users.watchingNowTvSlots,
|
||||
movieSlots: users.watchingNowMovieSlots,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const [plex, television, movies, history] = await Promise.all([
|
||||
getPlexLink(user.id),
|
||||
getWatchNow(user.id, "tv_series", user.tvSlots),
|
||||
getWatchNow(user.id, "movie", user.movieSlots),
|
||||
getWatchHistory(user.id),
|
||||
]);
|
||||
|
||||
const base = baseUrl.replace(/\/$/, "");
|
||||
|
||||
return {
|
||||
service: "ampelos",
|
||||
schemaVersion: PROFILE_SCHEMA_VERSION,
|
||||
generatedAt: new Date().toISOString(),
|
||||
user: { id: user.id, displayName: user.displayName, email: user.email },
|
||||
plex,
|
||||
watchNow: { television, movies },
|
||||
history,
|
||||
links: {
|
||||
self: `${base}/profile`,
|
||||
embed: `${base}/embed/profile`,
|
||||
api: `${base}/api/profile`,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user