diff --git a/src/components/plex/PlexAuthButton.vue b/src/components/plex/PlexAuthButton.vue
index 7334189..9a7a7a1 100644
--- a/src/components/plex/PlexAuthButton.vue
+++ b/src/components/plex/PlexAuthButton.vue
@@ -9,18 +9,9 @@
@@ -30,6 +21,7 @@
-
diff --git a/src/composables/usePlexApi.ts b/src/composables/usePlexApi.ts
index 474d047..b9f0c4b 100644
--- a/src/composables/usePlexApi.ts
+++ b/src/composables/usePlexApi.ts
@@ -1,200 +1,125 @@
import { ref } from "vue";
+import { API_HOSTNAME } from "../api";
// Shared constants - generated once and reused
export const CLIENT_IDENTIFIER = `seasoned-plex-app-${Math.random().toString(36).substring(7)}`;
export const APP_NAME = window.location.hostname;
-export function usePlexApi() {
- const plexServerUrl = ref("");
-
- // Fetch Plex user data
- async function fetchPlexUserData(authToken: string) {
- try {
- const response = await fetch("https://plex.tv/api/v2/user", {
- method: "GET",
- headers: {
- accept: "application/json",
- "X-Plex-Product": APP_NAME,
- "X-Plex-Client-Identifier": CLIENT_IDENTIFIER,
- "X-Plex-Token": authToken
- }
- });
-
- if (!response.ok) {
- throw new Error("Failed to fetch Plex user info");
+async function fetchPlexServers(authToken: string) {
+ try {
+ const url =
+ "https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1";
+ const options = {
+ method: "GET",
+ headers: {
+ accept: "application/json",
+ "X-Plex-Token": authToken,
+ "X-Plex-Client-Identifier": CLIENT_IDENTIFIER
}
+ };
- const data = await response.json();
+ const response = await fetch(url, options);
- // Convert Unix timestamp to ISO date string if needed
- let joinedDate = null;
- if (data.joinedAt) {
- if (typeof data.joinedAt === "number") {
- joinedDate = new Date(data.joinedAt * 1000).toISOString();
- } else {
- joinedDate = data.joinedAt;
- }
- }
-
- const userData = {
- id: data.id,
- uuid: data.uuid,
- username: data.username || data.title || "Plex User",
- email: data.email,
- thumb: data.thumb,
- joined_at: joinedDate,
- two_factor_enabled: data.twoFactorEnabled || false,
- experimental_features: data.experimentalFeatures || false,
- subscription: {
- active: data.subscription?.active,
- plan: data.subscription?.plan,
- features: data.subscription?.features
- },
- profile: {
- auto_select_audio: data.profile?.autoSelectAudio,
- default_audio_language: data.profile?.defaultAudioLanguage,
- default_subtitle_language: data.profile?.defaultSubtitleLanguage
- },
- entitlements: data.entitlements || [],
- roles: data.roles || [],
- created_at: new Date().toISOString()
- };
-
- localStorage.setItem("plex_user_data", JSON.stringify(userData));
- return userData;
- } catch (error) {
- console.error("[PlexAPI] Error fetching Plex user data:", error);
- return null;
+ if (!response.ok) {
+ throw new Error("Failed to fetch Plex servers");
}
- }
- // Fetch Plex servers
- async function fetchPlexServers(authToken: string) {
- try {
- const response = await fetch(
- "https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1",
- {
- method: "GET",
- headers: {
- accept: "application/json",
- "X-Plex-Token": authToken,
- "X-Plex-Client-Identifier": CLIENT_IDENTIFIER
- }
- }
- );
+ const servers = await response.json();
+ const ownedServer = servers.find(
+ (s: any) => s.owned && s.provides === "server"
+ );
- if (!response.ok) {
- throw new Error("Failed to fetch Plex servers");
- }
-
- const servers = await response.json();
- const ownedServer = servers.find(
- (s: any) => s.owned && s.provides === "server"
- );
-
- if (ownedServer) {
- const connection =
- ownedServer.connections?.find((c: any) => c.local === false) ||
- ownedServer.connections?.[0];
- if (connection) {
- plexServerUrl.value = connection.uri;
- }
- return {
- name: ownedServer.name,
- url: plexServerUrl.value,
- machineIdentifier: ownedServer.clientIdentifier
- };
- }
-
- return null;
- } catch (error) {
- console.error("[PlexAPI] Error fetching Plex servers:", error);
- return null;
- }
- }
-
- // Fetch library sections
- async function fetchLibrarySections(authToken: string, serverUrl: string) {
- if (!serverUrl) return [];
-
- try {
- const response = await fetch(`${serverUrl}/library/sections`, {
- method: "GET",
- headers: {
- accept: "application/json",
- "X-Plex-Token": authToken
- }
- });
-
- if (!response.ok) {
- throw new Error("Failed to fetch library sections");
- }
-
- const data = await response.json();
- return data.MediaContainer?.Directory || [];
- } catch (error) {
- console.error("[PlexAPI] Error fetching library sections:", error);
- return [];
- }
- }
-
- // Fetch library details
- async function fetchLibraryDetails(
- authToken: string,
- serverUrl: string,
- sectionKey: string
- ) {
- if (!serverUrl) return null;
-
- try {
- // Fetch all items
- const allResponse = await fetch(
- `${serverUrl}/library/sections/${sectionKey}/all`,
- {
- method: "GET",
- headers: {
- accept: "application/json",
- "X-Plex-Token": authToken
- }
- }
- );
-
- if (!allResponse.ok) throw new Error("Failed to fetch all items");
- const allData = await allResponse.json();
-
- // Fetch recently added
- const size = 20;
- const recentResponse = await fetch(
- `${serverUrl}/library/sections/${sectionKey}/recentlyAdded?X-Plex-Container-Start=0&X-Plex-Container-Size=${size}`,
- {
- method: "GET",
- headers: {
- accept: "application/json",
- "X-Plex-Token": authToken
- }
- }
- );
-
- if (!recentResponse.ok) throw new Error("Failed to fetch recently added");
- const recentData = await recentResponse.json();
+ if (ownedServer) {
+ const connection =
+ ownedServer.connections?.find((c: any) => c.local === false) ||
+ ownedServer.connections?.[0];
return {
- all: allData,
- recent: recentData,
- metadata: allData.MediaContainer?.Metadata || [],
- recentMetadata: recentData.MediaContainer?.Metadata || []
+ name: ownedServer.name,
+ url: connection?.uri,
+ machineIdentifier: ownedServer.clientIdentifier
};
- } catch (error) {
- console.error("[PlexAPI] Error fetching library details:", error);
- return null;
}
- }
- return {
- plexServerUrl,
- fetchPlexUserData,
- fetchPlexServers,
- fetchLibrarySections,
- fetchLibraryDetails
- };
+ return null;
+ } catch (error) {
+ console.error("[PlexAPI] Error fetching Plex servers:", error);
+ return null;
+ }
}
+
+async function fetchPlexUserData(authToken: string) {
+ try {
+ const url = "https://plex.tv/api/v2/user";
+ const options = {
+ method: "GET",
+ headers: {
+ accept: "application/json",
+ "X-Plex-Product": APP_NAME,
+ "X-Plex-Client-Identifier": CLIENT_IDENTIFIER,
+ "X-Plex-Token": authToken
+ }
+ };
+
+ const response = await fetch(url, options);
+
+ if (!response.ok) {
+ throw new Error("Failed to fetch Plex user info");
+ }
+
+ const data = await response.json();
+
+ // Convert Unix timestamp to ISO date string if needed
+ let joinedDate = null;
+ if (data.joinedAt) {
+ if (typeof data.joinedAt === "number") {
+ joinedDate = new Date(data.joinedAt * 1000).toISOString();
+ } else {
+ joinedDate = data.joinedAt;
+ }
+ }
+
+ const userData = {
+ id: data.id,
+ uuid: data.uuid,
+ username: data.username || data.title || "Plex User",
+ email: data.email,
+ thumb: data.thumb,
+ joined_at: joinedDate,
+ two_factor_enabled: data.twoFactorEnabled || false,
+ experimental_features: data.experimentalFeatures || false,
+ subscription: {
+ active: data.subscription?.active,
+ plan: data.subscription?.plan,
+ features: data.subscription?.features
+ },
+ profile: {
+ auto_select_audio: data.profile?.autoSelectAudio,
+ default_audio_language: data.profile?.defaultAudioLanguage,
+ default_subtitle_language: data.profile?.defaultSubtitleLanguage
+ },
+ entitlements: data.entitlements || [],
+ roles: data.roles || [],
+ created_at: new Date().toISOString()
+ };
+
+ return userData;
+ } catch (error) {
+ console.error("[PlexAPI] Error fetching Plex user data:", error);
+ return null;
+ }
+}
+
+// Fetch library details
+async function fetchLibraryDetails() {
+ try {
+ const url = `${API_HOSTNAME}/api/v2/plex/library`;
+ const options: RequestInit = { credentials: "include" };
+ return await fetch(url, options).then(resp => resp.json());
+ } catch (error) {
+ console.error("[PlexAPI] error fetching library:", error);
+ return null;
+ }
+}
+
+export { fetchPlexServers, fetchPlexUserData, fetchLibraryDetails };
diff --git a/src/composables/usePlexAuth.ts b/src/composables/usePlexAuth.ts
index ac63370..ea29b53 100644
--- a/src/composables/usePlexAuth.ts
+++ b/src/composables/usePlexAuth.ts
@@ -9,15 +9,17 @@ export function usePlexAuth() {
// Generate a PIN for Plex OAuth
async function generatePlexPin() {
try {
- const response = await fetch("https://plex.tv/api/v2/pins?strong=true", {
+ const url = "https://plex.tv/api/v2/pins?strong=true";
+ const options = {
method: "POST",
headers: {
accept: "application/json",
"X-Plex-Product": APP_NAME,
"X-Plex-Client-Identifier": CLIENT_IDENTIFIER
}
- });
+ };
+ const response = await fetch(url, options);
if (!response.ok) throw new Error("Failed to generate PIN");
const data = await response.json();
return { id: data.id, code: data.code };
@@ -30,15 +32,15 @@ export function usePlexAuth() {
// Check PIN status
async function checkPin(pinId: number, pinCode: string) {
try {
- const response = await fetch(
- `https://plex.tv/api/v2/pins/${pinId}?code=${pinCode}`,
- {
- headers: {
- accept: "application/json",
- "X-Plex-Client-Identifier": CLIENT_IDENTIFIER
- }
+ const url = `https://plex.tv/api/v2/pins/${pinId}?code=${pinCode}`;
+ const options = {
+ headers: {
+ accept: "application/json",
+ "X-Plex-Client-Identifier": CLIENT_IDENTIFIER
}
- );
+ };
+
+ const response = await fetch(url, options);
if (!response.ok) return null;
const data = await response.json();
@@ -93,9 +95,10 @@ export function usePlexAuth() {
}
// Get cookie
- function getCookie(name: string): string | null {
+ function getPlexAuthCookie(): string | null {
+ const key = "plex_auth_token";
const value = `; ${document.cookie}`;
- const parts = value.split(`; ${name}=`);
+ const parts = value.split(`; ${key}=`);
if (parts.length === 2) {
return parts.pop()?.split(";").shift() || null;
}
@@ -171,9 +174,10 @@ export function usePlexAuth() {
if (plexPopup.value && plexPopup.value.closed) {
clearInterval(popupChecker);
stopPolling();
+
if (loading.value) {
loading.value = false;
- onError("Plex authentication window was closed");
+ // onError("Plex authentication window was closed");
}
}
}, 500);
@@ -190,7 +194,7 @@ export function usePlexAuth() {
return {
loading,
setPlexAuthCookie,
- getCookie,
+ getPlexAuthCookie,
openAuthPopup,
cleanup
};
diff --git a/src/composables/usePlexLibraries.ts b/src/composables/usePlexLibraries.ts
deleted file mode 100644
index b17fbfc..0000000
--- a/src/composables/usePlexLibraries.ts
+++ /dev/null
@@ -1,169 +0,0 @@
-import {
- processLibraryItem,
- calculateGenreStats,
- calculateDuration
-} from "@/utils/plexHelpers";
-
-export function usePlexLibraries() {
- async function loadLibraries(
- sections: any[],
- authToken: string,
- serverUrl: string,
- machineIdentifier: string,
- username: string,
- fetchLibraryDetailsFn: any
- ) {
- // Reset stats
- const stats = { movies: 0, shows: 0, music: 0, watchtime: 0 };
- const details: any = {
- movies: {
- total: 0,
- recentlyAdded: [],
- genres: [],
- totalDuration: "0 hours"
- },
- shows: {
- total: 0,
- recentlyAdded: [],
- genres: [],
- totalEpisodes: 0,
- totalDuration: "0 hours"
- },
- music: { total: 0, recentlyAdded: [], genres: [], totalTracks: 0 }
- };
-
- try {
- for (const section of sections) {
- const { type } = section;
- const { key } = section;
-
- if (type === "movie") {
- await processLibrarySection(
- authToken,
- serverUrl,
- machineIdentifier,
- key,
- "movies",
- stats,
- details,
- fetchLibraryDetailsFn
- );
- } else if (type === "show") {
- await processLibrarySection(
- authToken,
- serverUrl,
- machineIdentifier,
- key,
- "shows",
- stats,
- details,
- fetchLibraryDetailsFn
- );
- } else if (type === "artist") {
- await processLibrarySection(
- authToken,
- serverUrl,
- machineIdentifier,
- key,
- "music",
- stats,
- details,
- fetchLibraryDetailsFn
- );
- }
- }
-
- // Calculate watchtime from Tautulli if username provided
- if (username) {
- try {
- const TAUTULLI_API_KEY = "28494032b47542278fe76c6ccd1f0619";
- const TAUTULLI_BASE_URL = "http://plex.schleppe:8181/api/v2";
- const url = `${TAUTULLI_BASE_URL}?apikey=${TAUTULLI_API_KEY}&cmd=get_history&user=${encodeURIComponent(
- username
- )}&length=8000`;
- const response = await fetch(url);
- if (response.ok) {
- const data = await response.json();
- const history = data.response?.data?.data || [];
- const totalMs = history.reduce(
- (sum: number, item: any) => sum + (item.duration || 0) * 1000,
- 0
- );
- stats.watchtime = Math.round(totalMs / (1000 * 60 * 60));
- }
- } catch (error) {
- console.error("[PlexLibraries] Error fetching watchtime:", error);
- }
- }
-
- return { stats, details };
- } catch (error) {
- console.error("[PlexLibraries] Error loading libraries:", error);
- throw error;
- }
- }
-
- async function processLibrarySection(
- authToken: string,
- serverUrl: string,
- machineIdentifier: string,
- sectionKey: string,
- libraryType: string,
- stats: any,
- details: any,
- fetchLibraryDetailsFn: any
- ) {
- try {
- const data = await fetchLibraryDetailsFn(
- authToken,
- serverUrl,
- sectionKey
- );
- if (!data) return;
-
- const totalCount = data.all.MediaContainer?.size || 0;
-
- // Update stats
- if (libraryType === "movies") {
- stats.movies += totalCount;
- } else if (libraryType === "shows") {
- stats.shows += totalCount;
- } else if (libraryType === "music") {
- stats.music += totalCount;
- }
-
- // Process recently added items
- const recentItems = data.recentMetadata.map((item: any) =>
- processLibraryItem(
- item,
- libraryType,
- authToken,
- serverUrl,
- machineIdentifier
- )
- );
-
- // Calculate stats
- const genres = calculateGenreStats(data.metadata);
- const durations = calculateDuration(data.metadata, libraryType);
-
- // Update library details
- details[libraryType] = {
- total: totalCount,
- recentlyAdded: recentItems,
- genres,
- totalDuration: durations.totalDuration,
- ...(libraryType === "shows" && {
- totalEpisodes: durations.totalEpisodes
- }),
- ...(libraryType === "music" && { totalTracks: durations.totalTracks })
- };
- } catch (error) {
- console.error(`[PlexLibraries] Error processing ${libraryType}:`, error);
- }
- }
-
- return {
- loadLibraries
- };
-}
diff --git a/src/icons/IconPlex.vue b/src/icons/IconPlex.vue
new file mode 100644
index 0000000..260ba56
--- /dev/null
+++ b/src/icons/IconPlex.vue
@@ -0,0 +1,10 @@
+
+
+
diff --git a/src/icons/IconServer.vue b/src/icons/IconServer.vue
new file mode 100644
index 0000000..3a29d12
--- /dev/null
+++ b/src/icons/IconServer.vue
@@ -0,0 +1,17 @@
+
+
+
diff --git a/src/icons/IconSync.vue b/src/icons/IconSync.vue
new file mode 100644
index 0000000..cac8a29
--- /dev/null
+++ b/src/icons/IconSync.vue
@@ -0,0 +1,16 @@
+
+
+
diff --git a/src/modules/user.ts b/src/modules/user.ts
index f81c30c..2ac1bdd 100644
--- a/src/modules/user.ts
+++ b/src/modules/user.ts
@@ -16,12 +16,13 @@ export interface CookieOptions {
/**
* Read a cookie value.
*/
-export function getCookie(name: string): string | null {
+export function getAuthorizationCookie(): string | null {
+ const key = 'authorization';
const array = document.cookie.split(";");
let match = null;
array.forEach((item: string) => {
- const query = `${name}=`;
+ const query = `${key}=`;
if (!item.trim().startsWith(query)) return;
match = item.trim().substring(query.length);
});
@@ -132,7 +133,7 @@ const userModule: Module = {
/* ── Actions ─────────────────────────────────────────────────── */
actions: {
async initUserFromCookie({ dispatch }): Promise {
- const jwtToken = getCookie("authorization");
+ const jwtToken = getAuthorizationCookie();
if (!jwtToken) return null;
const token = parseJwt(jwtToken);
diff --git a/src/pages/MissingPlexAuthPage.vue b/src/pages/MissingPlexAuthPage.vue
new file mode 100644
index 0000000..55c72a9
--- /dev/null
+++ b/src/pages/MissingPlexAuthPage.vue
@@ -0,0 +1,53 @@
+
+
+
Must be authenticated with Plex
+
Go to Settings to link your Plex account
+
+
+
+
+
+
diff --git a/src/routes.ts b/src/routes.ts
index bcf4f86..084aaff 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -3,6 +3,8 @@ import type { RouteRecordRaw, RouteLocationNormalized } from "vue-router";
/* eslint-disable-next-line import-x/no-cycle */
import store from "./store";
+import { usePlexAuth } from "./composables/usePlexAuth";
+const { getPlexAuthCookie } = usePlexAuth();
declare global {
interface Window {
@@ -83,6 +85,11 @@ const routes: RouteRecordRaw[] = [
meta: { requiresAuth: true },
component: () => import("./pages/AdminPage.vue")
},
+ {
+ name: "missing-plex-auth",
+ path: "/missing/plex",
+ component: () => import("./pages/MissingPlexAuthPage.vue")
+ },
{
path: "/:pathMatch(.*)*",
name: "NotFound",
@@ -111,16 +118,8 @@ const hasPlexAccount = () => {
if (store.getters["user/plexUserId"] !== null) return true;
// Fallback to localStorage
- const userData = localStorage.getItem("plex_user_data");
- if (userData) {
- try {
- const parsed = JSON.parse(userData);
- return parsed.id !== null && parsed.id !== undefined;
- } catch {
- return false;
- }
- }
- return false;
+ const authToken = getPlexAuthCookie();
+ return !!authToken;
};
const hamburgerIsOpen = () => store.getters["hamburger/isOpen"];
@@ -136,15 +135,14 @@ router.beforeEach(
// send user to signin page.
if (to.matched.some(record => record.meta.requiresAuth)) {
if (!loggedIn()) {
- next({ path: "/signin" });
+ next({ path: "/login" });
}
}
if (to.matched.some(record => record.meta.requiresPlexAccount)) {
if (!hasPlexAccount()) {
next({
- path: "/settings",
- query: { missingPlexAccount: true }
+ path: "/missing/plex"
});
}
}
diff --git a/src/utils/plexHelpers.ts b/src/utils/plexHelpers.ts
index 226b9dd..490839c 100644
--- a/src/utils/plexHelpers.ts
+++ b/src/utils/plexHelpers.ts
@@ -1,7 +1,7 @@
export function getLibraryIcon(type: string): string {
const icons: Record = {
movies: "🎬",
- shows: "📺",
+ "tv shows": "📺",
music: "🎵"
};
return icons[type] || "📁";
@@ -10,7 +10,7 @@ export function getLibraryIcon(type: string): string {
export function getLibraryIconComponent(type: string): string {
const components: Record = {
movies: "IconMovie",
- shows: "IconShow",
+ "tv shows": "IconShow",
music: "IconMusic"
};
return components[type] || "IconMovie";
@@ -19,7 +19,7 @@ export function getLibraryIconComponent(type: string): string {
export function getLibraryTitle(type: string): string {
const titles: Record = {
movies: "Movies",
- shows: "TV Shows",
+ "tv shows": "TV Shows",
music: "Music"
};
return titles[type] || type;
@@ -62,8 +62,8 @@ export function processLibraryItem(
// Get poster/thumbnail URL
let posterUrl = null;
- // For TV shows, prefer grandparentThumb (show poster) over thumb (episode thumbnail)
- if (libraryType === "shows") {
+ // For TV tv shows, prefer grandparentThumb (show poster) over thumb (episode thumbnail)
+ if (libraryType === "tv shows") {
if (item.grandparentThumb) {
posterUrl = `${serverUrl}${item.grandparentThumb}?X-Plex-Token=${authToken}`;
} else if (item.thumb) {
@@ -92,14 +92,14 @@ export function processLibraryItem(
plexUrl = `https://app.plex.tv/desktop/#!/server/${machineIdentifier}/details?key=${encodedKey}`;
}
- // For shows, use grandparent data (show info) instead of episode info
+ // For tv shows, use grandparent data (show info) instead of episode info
const title =
- libraryType === "shows" && item.grandparentTitle
+ libraryType === "tv shows" && item.grandparentTitle
? item.grandparentTitle
: item.title;
const year =
- libraryType === "shows" && item.grandparentYear
+ libraryType === "tv shows" && item.grandparentYear
? item.grandparentYear
: item.year || item.parentYear || new Date().getFullYear();
@@ -109,11 +109,12 @@ export function processLibraryItem(
poster: posterUrl,
fallbackIcon: getLibraryIcon(libraryType),
rating: item.rating ? Math.round(item.rating * 10) / 10 : null,
+ type: libraryType,
ratingKey,
plexUrl
};
- if (libraryType === "shows") {
+ if (libraryType === "tv shows") {
return {
...baseItem,
episodes: item.leafCount || 0
@@ -157,7 +158,7 @@ export function calculateDuration(metadata: any[], libraryType: string) {
totalDuration += item.duration;
}
- if (libraryType === "shows" && item.leafCount) {
+ if (libraryType === "tv shows" && item.leafCount) {
totalEpisodes += item.leafCount;
} else if (libraryType === "music" && item.leafCount) {
totalTracks += item.leafCount;