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:
+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 {
|
||||
|
||||
Reference in New Issue
Block a user