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 @@ + + + + + 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;