diff --git a/src/components/plex/PlexAuthButton.vue b/src/components/plex/PlexAuthButton.vue new file mode 100644 index 0000000..7334189 --- /dev/null +++ b/src/components/plex/PlexAuthButton.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/src/components/plex/PlexProfileCard.vue b/src/components/plex/PlexProfileCard.vue new file mode 100644 index 0000000..5f9a923 --- /dev/null +++ b/src/components/plex/PlexProfileCard.vue @@ -0,0 +1,309 @@ + + + + + diff --git a/src/composables/usePlexApi.ts b/src/composables/usePlexApi.ts new file mode 100644 index 0000000..0e27ec0 --- /dev/null +++ b/src/composables/usePlexApi.ts @@ -0,0 +1,195 @@ +import { ref } from "vue"; + +const CLIENT_IDENTIFIER = + "seasoned-plex-app-" + Math.random().toString(36).substring(7); +const APP_NAME = "Seasoned"; + +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"); + } + + const data = await response.json(); + console.log("[PlexAPI] Raw Plex API response:", data); + + // 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() + }; + + console.log("[PlexAPI] Processed user data:", userData); + localStorage.setItem("plex_user_data", JSON.stringify(userData)); + return userData; + } catch (error) { + console.error("[PlexAPI] Error fetching Plex user data:", error); + return null; + } + } + + // 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 + } + } + ); + + 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 }; + } + + return null; + } catch (error) { + console.error("[PlexAPI] Error fetching Plex servers:", error); + return null; + } + } + + // Fetch library sections + async function fetchLibrarySections(authToken: string) { + if (!plexServerUrl.value) return []; + + try { + const response = await fetch(`${plexServerUrl.value}/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, sectionKey: string) { + if (!plexServerUrl.value) return null; + + try { + // Fetch all items + const allResponse = await fetch( + `${plexServerUrl.value}/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 recentResponse = await fetch( + `${plexServerUrl.value}/library/sections/${sectionKey}/recentlyAdded?X-Plex-Container-Start=0&X-Plex-Container-Size=5`, + { + 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(); + + return { + all: allData, + recent: recentData, + metadata: allData.MediaContainer?.Metadata || [], + recentMetadata: recentData.MediaContainer?.Metadata || [] + }; + } catch (error) { + console.error("[PlexAPI] Error fetching library details:", error); + return null; + } + } + + return { + CLIENT_IDENTIFIER, + APP_NAME, + plexServerUrl, + fetchPlexUserData, + fetchPlexServers, + fetchLibrarySections, + fetchLibraryDetails + }; +} diff --git a/src/composables/usePlexAuth.ts b/src/composables/usePlexAuth.ts new file mode 100644 index 0000000..3a72218 --- /dev/null +++ b/src/composables/usePlexAuth.ts @@ -0,0 +1,199 @@ +import { ref } from "vue"; +import { usePlexApi } from "./usePlexApi"; + +export function usePlexAuth() { + const { CLIENT_IDENTIFIER, APP_NAME } = usePlexApi(); + + const loading = ref(false); + const plexPopup = ref(null); + const pollInterval = ref(null); + + // Generate a PIN for Plex OAuth + async function generatePlexPin() { + try { + const response = await fetch("https://plex.tv/api/v2/pins?strong=true", { + method: "POST", + headers: { + accept: "application/json", + "X-Plex-Product": APP_NAME, + "X-Plex-Client-Identifier": CLIENT_IDENTIFIER + } + }); + + if (!response.ok) throw new Error("Failed to generate PIN"); + const data = await response.json(); + return { id: data.id, code: data.code }; + } catch (error) { + console.error("[PlexAuth] Error generating PIN:", error); + return null; + } + } + + // 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 + } + } + ); + + if (!response.ok) return null; + const data = await response.json(); + return data.authToken; + } catch (error) { + console.error("[PlexAuth] Error checking PIN:", error); + return null; + } + } + + // Construct auth URL + function constructAuthUrl(pinCode: string) { + const params = new URLSearchParams({ + clientID: CLIENT_IDENTIFIER, + code: pinCode, + "context[device][product]": APP_NAME + }); + return `https://app.plex.tv/auth#?${params.toString()}`; + } + + // Start polling for PIN + function startPolling( + pinId: number, + pinCode: string, + onSuccess: (token: string) => void + ) { + pollInterval.value = window.setInterval(async () => { + const authToken = await checkPin(pinId, pinCode); + if (authToken) { + stopPolling(); + if (plexPopup.value && !plexPopup.value.closed) { + plexPopup.value.close(); + } + onSuccess(authToken); + } + }, 1000); + } + + // Stop polling + function stopPolling() { + if (pollInterval.value) { + clearInterval(pollInterval.value); + pollInterval.value = null; + } + } + + // Set cookie + function setPlexAuthCookie(authToken: string) { + const expires = new Date(); + expires.setDate(expires.getDate() + 30); + document.cookie = `plex_auth_token=${authToken}; path=/; expires=${expires.toUTCString()}; SameSite=Strict`; + } + + // Get cookie + function getCookie(name: string): string | null { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) { + return parts.pop()?.split(";").shift() || null; + } + return null; + } + + // Open authentication popup + async function openAuthPopup( + onSuccess: (token: string) => void, + onError: (msg: string) => void + ) { + loading.value = true; + + const width = 600; + const height = 700; + const left = window.screen.width / 2 - width / 2; + const top = window.screen.height / 2 - height / 2; + + plexPopup.value = window.open( + "about:blank", + "PlexAuth", + `width=${width},height=${height},left=${left},top=${top}` + ); + + if (!plexPopup.value) { + onError("Please allow popups for this site to authenticate with Plex"); + loading.value = false; + return; + } + + // Add loading screen + if (plexPopup.value.document) { + plexPopup.value.document.write(` + + + Connecting to Plex... + + +

Connecting to Plex...

+ + `); + } + + const pin = await generatePlexPin(); + if (!pin) { + if (plexPopup.value && !plexPopup.value.closed) plexPopup.value.close(); + onError("Could not generate Plex authentication PIN"); + loading.value = false; + return; + } + + const authUrl = constructAuthUrl(pin.code); + if (plexPopup.value && !plexPopup.value.closed) { + plexPopup.value.location.href = authUrl; + } else { + onError("Authentication window was closed"); + loading.value = false; + return; + } + + startPolling(pin.id, pin.code, onSuccess); + + // Check if popup closed + const popupChecker = setInterval(() => { + if (plexPopup.value && plexPopup.value.closed) { + clearInterval(popupChecker); + stopPolling(); + if (loading.value) { + loading.value = false; + onError("Plex authentication window was closed"); + } + } + }, 500); + } + + // Cleanup + function cleanup() { + stopPolling(); + if (plexPopup.value && !plexPopup.value.closed) { + plexPopup.value.close(); + } + } + + return { + loading, + setPlexAuthCookie, + getCookie, + openAuthPopup, + cleanup + }; +}