mirror of
https://github.com/KevinMidboe/seasoned.git
synced 2026-03-11 11:55:38 +00:00
Refactor: Extract Plex composables and smaller components
Split large PlexSettings component into reusable pieces: Composables: - usePlexApi.ts: API functions for user data, servers, libraries - usePlexAuth.ts: OAuth authentication flow, PIN generation, polling Components: - PlexAuthButton.vue: Sign-in button with OAuth popup - PlexProfileCard.vue: User profile with badges (Pass, 2FA, Labs, years) Benefits: - Better code organization and maintainability - Reusable authentication logic - Cleaner separation of concerns - Easier testing and debugging
This commit is contained in:
149
src/components/plex/PlexAuthButton.vue
Normal file
149
src/components/plex/PlexAuthButton.vue
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<template>
|
||||||
|
<div class="plex-connect">
|
||||||
|
<div class="info-box">
|
||||||
|
<IconInfo class="info-icon" />
|
||||||
|
<p>
|
||||||
|
Sign in to your Plex account to get information about recently added
|
||||||
|
movies and to see your watch history
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="signin-container">
|
||||||
|
<button @click="handleAuth" :disabled="loading" class="plex-signin-btn">
|
||||||
|
<svg
|
||||||
|
v-if="!loading"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 256 256"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M128 0C57.3 0 0 57.3 0 128s57.3 128 128 128 128-57.3 128-128S198.7 0 128 0zm57.7 128.7l-48 48c-.4.4-.9.7-1.4.9-.5.2-1.1.4-1.6.4s-1.1-.1-1.6-.4c-.5-.2-1-.5-1.4-.9l-48-48c-1.6-1.6-1.6-4.1 0-5.7 1.6-1.6 4.1-1.6 5.7 0l41.1 41.1V80c0-2.2 1.8-4 4-4s4 1.8 4 4v84.1l41.1-41.1c1.6-1.6 4.1-1.6 5.7 0 .8.8 1.2 1.8 1.2 2.8s-.4 2.1-1.2 2.9z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{{ loading ? "Connecting..." : "Sign in with Plex" }}
|
||||||
|
</button>
|
||||||
|
<p class="popup-note">A popup window will open for authentication</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { usePlexAuth } from "@/composables/usePlexAuth";
|
||||||
|
import IconInfo from "@/icons/IconInfo.vue";
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
authSuccess: [token: string];
|
||||||
|
authError: [message: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { loading, openAuthPopup } = usePlexAuth();
|
||||||
|
|
||||||
|
function handleAuth() {
|
||||||
|
openAuthPopup(
|
||||||
|
token => emit("authSuccess", token),
|
||||||
|
error => emit("authError", error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "scss/variables";
|
||||||
|
@import "scss/media-queries";
|
||||||
|
|
||||||
|
.info-box {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.65rem;
|
||||||
|
padding: 0.65rem;
|
||||||
|
background-color: var(--background-ui);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
border-left: 3px solid var(--highlight-color);
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
padding: 0.6rem;
|
||||||
|
gap: 0.55rem;
|
||||||
|
margin-bottom: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
fill: var(--highlight-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.signin-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plex-signin-btn {
|
||||||
|
padding: 1rem 1.75rem;
|
||||||
|
background-color: #c87818;
|
||||||
|
color: $white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-self: flex-start;
|
||||||
|
box-shadow: 0 4px 12px rgba(200, 120, 24, 0.25);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.9rem 1.4rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background-color: #b36a15;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 20px rgba(200, 120, 24, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 4px 12px rgba(200, 120, 24, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-note {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
309
src/components/plex/PlexProfileCard.vue
Normal file
309
src/components/plex/PlexProfileCard.vue
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="username" class="plex-profile-card">
|
||||||
|
<div class="profile-header">
|
||||||
|
<img
|
||||||
|
v-if="userData?.thumb"
|
||||||
|
:src="userData.thumb"
|
||||||
|
alt="Profile"
|
||||||
|
class="profile-avatar"
|
||||||
|
/>
|
||||||
|
<div v-else class="profile-avatar-placeholder">
|
||||||
|
{{ username.charAt(0).toUpperCase() }}
|
||||||
|
</div>
|
||||||
|
<div class="profile-info">
|
||||||
|
<div class="username-row">
|
||||||
|
<h3 class="profile-username">{{ username }}</h3>
|
||||||
|
<svg
|
||||||
|
class="connected-checkmark"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="3"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="20 6 9 17 4 12"></polyline>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div v-if="userData?.email" class="profile-email">
|
||||||
|
{{ userData.email }}
|
||||||
|
</div>
|
||||||
|
<div class="profile-badges">
|
||||||
|
<div
|
||||||
|
v-if="userData?.subscription?.active"
|
||||||
|
class="profile-badge plex-pass"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 256 256"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M128 0C57.3 0 0 57.3 0 128s57.3 128 128 128 128-57.3 128-128S198.7 0 128 0zm0 230.4C66.9 230.4 25.6 189.1 25.6 128S66.9 25.6 128 25.6 230.4 66.9 230.4 128 189.1 230.4 128 230.4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Plex Pass
|
||||||
|
</div>
|
||||||
|
<div v-if="userData?.joined_at" class="profile-badge member-since">
|
||||||
|
{{ formatMemberSince(userData.joined_at) }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="userData?.two_factor_enabled"
|
||||||
|
class="profile-badge two-factor"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
>
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||||
|
</svg>
|
||||||
|
2FA
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="userData?.experimental_features"
|
||||||
|
class="profile-badge experimental"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
Labs
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
username: string;
|
||||||
|
userData: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>();
|
||||||
|
|
||||||
|
function formatMemberSince(dateString: string) {
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const years = now.getFullYear() - date.getFullYear();
|
||||||
|
|
||||||
|
if (years === 0) return "New Member";
|
||||||
|
if (years === 1) return "1 Year";
|
||||||
|
return `${years} Years`;
|
||||||
|
} catch {
|
||||||
|
return "Member";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "scss/variables";
|
||||||
|
@import "scss/media-queries";
|
||||||
|
|
||||||
|
.plex-profile-card {
|
||||||
|
background-color: var(--background-ui);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
border: 1px solid var(--background-40);
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
padding: 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.85rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 2px solid var(--highlight-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-placeholder {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--highlight-color),
|
||||||
|
var(--background-40)
|
||||||
|
);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-username {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.connected-checkmark {
|
||||||
|
color: var(--color-success-highlight);
|
||||||
|
flex-shrink: 0;
|
||||||
|
animation: checkmarkPop 0.3s ease-out;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes checkmarkPop {
|
||||||
|
0% {
|
||||||
|
transform: scale(0);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-email {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-color-60);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
word-break: break-all;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.25rem 0.65rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.plex-pass {
|
||||||
|
background-color: #cc7b19;
|
||||||
|
color: $white;
|
||||||
|
text-transform: uppercase;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.member-since {
|
||||||
|
background-color: var(--background-40);
|
||||||
|
color: var(--text-color-70);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.two-factor {
|
||||||
|
background-color: var(--color-success);
|
||||||
|
color: $white;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.experimental {
|
||||||
|
background-color: #8b5cf6;
|
||||||
|
color: $white;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
195
src/composables/usePlexApi.ts
Normal file
195
src/composables/usePlexApi.ts
Normal file
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
199
src/composables/usePlexAuth.ts
Normal file
199
src/composables/usePlexAuth.ts
Normal file
@@ -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<Window | null>(null);
|
||||||
|
const pollInterval = ref<number | null>(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(`
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Connecting to Plex...</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; padding: 0; display: flex; justify-content: center; align-items: center;
|
||||||
|
height: 100vh; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: #1c3a13; color: #fcfcf7; }
|
||||||
|
.spinner { border: 4px solid rgba(252, 252, 247, 0.3); border-top: 4px solid #fcfcf7;
|
||||||
|
border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 20px; }
|
||||||
|
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body><div class="loader"><div class="spinner"></div><p>Connecting to Plex...</p></div></body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user