Add Plex integration icons and improve authentication flow

- Create IconPlex with play button symbol for Plex login button
- Create IconServer for server information display
- Create IconSync for library sync operations
- Replace inline SVGs with icon components in PlexAuthButton
- Replace inline SVGs with icon components in PlexServerInfo
- Add MissingPlexAuthPage for better auth error handling
- Update routes to redirect missing Plex auth to dedicated page
- Refactor usePlexApi and usePlexAuth for better composable patterns
- Remove deprecated usePlexLibraries composable
- Improve PlexLibraryModal and PlexLibraryStats components
- Clean up Plex-related helper utilities
This commit is contained in:
2026-03-08 20:57:08 +01:00
parent 493ac02bab
commit 990dde4d31
15 changed files with 373 additions and 471 deletions

View File

@@ -9,18 +9,9 @@
</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" }}
<IconPlex v-if="!loading" class="plex-icon" />
</button>
<p class="popup-note">A popup window will open for authentication</p>
</div>
@@ -30,6 +21,7 @@
<script setup lang="ts">
import { usePlexAuth } from "@/composables/usePlexAuth";
import IconInfo from "@/icons/IconInfo.vue";
import IconPlex from "@/icons/IconPlex.vue";
const emit = defineEmits<{
authSuccess: [token: string];
@@ -134,10 +126,12 @@
cursor: not-allowed;
}
svg {
.plex-icon {
flex-shrink: 0;
width: 22px;
height: 22px;
--size: 24px;
width: var(--size);
height: var(--size);
fill: currentColor;
}
}

View File

@@ -6,7 +6,7 @@
rel="noopener noreferrer"
class="plex-library-item"
>
<figure class="item-poster">
<figure :class="`item-poster ${item.type}`">
<img
v-if="item.poster"
:src="item.poster"
@@ -113,7 +113,7 @@
}
</script>
<style scoped>
<style style="scss" scoped>
.plex-library-item {
display: flex;
flex-direction: column;
@@ -144,6 +144,10 @@
overflow: hidden;
background: #333;
margin: 0;
&.music {
aspect-ratio: 1/1;
}
}
.poster-image {

View File

@@ -21,19 +21,33 @@
<div class="library-stats-overview">
<div class="overview-stat">
<span class="overview-label">Total Items</span>
<span class="overview-value">{{ details.total }}</span>
<span class="overview-value">{{
formatNumber(details.total)
}}</span>
</div>
<div class="overview-stat" v-if="libraryType === 'shows'">
<div class="overview-stat" v-if="libraryType === 'tv shows'">
<span class="overview-label">Seasons</span>
<span class="overview-value">{{
formatNumber(details?.childCount)
}}</span>
</div>
<div class="overview-stat" v-if="libraryType === 'tv shows'">
<span class="overview-label">Episodes</span>
<span class="overview-value">{{ details.totalEpisodes }}</span>
<span class="overview-value">{{
formatNumber(details?.leafCount)
}}</span>
</div>
<div class="overview-stat" v-if="libraryType === 'music'">
<span class="overview-label">Tracks</span>
<span class="overview-value">{{ details.totalTracks }}</span>
<span class="overview-value">{{ details?.totalTracks }}</span>
</div>
<div class="overview-stat">
<span class="overview-label">Duration</span>
<span class="overview-value">{{ details.totalDuration }}</span>
<span class="overview-value">{{
convertSecondsToHumanReadable(details?.duration / 1000)
}}</span>
</div>
</div>
@@ -42,10 +56,12 @@
<h4 class="section-title">Recently Added</h4>
<div class="recent-items-grid">
<PlexLibraryItem
v-for="(item, index) in details.recentlyAdded"
v-for="(item, index) in recentlyAdded"
:key="index"
:item="item"
:show-extras="libraryType === 'music' || libraryType === 'shows'"
:show-extras="
libraryType === 'music' || libraryType === 'tv shows'
"
/>
</div>
</div>
@@ -78,41 +94,70 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount } from "vue";
import { computed, onMounted, onBeforeUnmount, ref } from "vue";
import IconClose from "@/icons/IconClose.vue";
import IconMovie from "@/icons/IconMovie.vue";
import IconShow from "@/icons/IconShow.vue";
import IconMusic from "@/icons/IconMusic.vue";
import PlexLibraryItem from "@/components/plex/PlexLibraryItem.vue";
import { getLibraryTitle } from "@/utils/plexHelpers";
import { plexRecentlyAddedInLibrary } from "@/api";
import { processLibraryItem } from "@/utils/plexHelpers";
import { formatNumber, convertSecondsToHumanReadable } from "@/utils";
import { usePlexAuth } from "@/composables/usePlexAuth";
const { getPlexAuthCookie } = usePlexAuth();
const authToken = getPlexAuthCookie();
interface LibraryDetails {
id: number;
title: string;
total: number;
recentlyAdded: any[];
genres: { name: string; count: number }[];
totalDuration: string;
totalEpisodes?: number;
totalTracks?: number;
childCount?: number;
leafCount?: number;
duration: number;
genres: Array<{
name: string;
count: number;
}>;
}
interface Props {
libraryType: string;
details: LibraryDetails;
serverUrl: string;
serverMachineId: string;
}
const props = defineProps<Props>();
let recentlyAdded = ref([]);
const emit = defineEmits<{
(e: "close"): void;
}>();
const libraryIconComponent = computed(() => {
if (props.libraryType === "movies") return IconMovie;
if (props.libraryType === "shows") return IconShow;
if (props.libraryType === "tv shows") return IconShow;
if (props.libraryType === "music") return IconMusic;
return IconMovie;
});
function fetchRecentlyAdded() {
plexRecentlyAddedInLibrary(props.details.id).then(added => {
recentlyAdded.value = added?.MediaContainer?.Metadata.map(el =>
processLibraryItem(
el,
props.libraryType,
authToken,
props.serverUrl,
props.serverMachineId
)
);
});
}
function checkEventForEscapeKey(event: KeyboardEvent) {
if (event.key !== "Escape") return;
emit("close");
@@ -120,12 +165,18 @@
window.addEventListener("keyup", checkEventForEscapeKey);
onMounted(() => {
fetchRecentlyAdded();
});
onBeforeUnmount(() => {
window.removeEventListener("keyup", checkEventForEscapeKey);
});
</script>
<style scoped>
<style lang="scss" scoped>
@import "scss/media-queries.scss";
.modal-overlay {
position: fixed;
top: 0;
@@ -139,6 +190,10 @@
justify-content: center;
z-index: 1000;
padding: 20px;
@include mobile {
padding: 0;
}
}
.library-modal-content {
@@ -150,6 +205,11 @@
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
@include mobile {
max-height: 100vh;
border-radius: unset;
}
}
.library-modal-header {
@@ -198,12 +258,16 @@
border: none;
color: #888;
cursor: pointer;
padding: 8px;
padding: 0.5rem;
height: var(--size);
width: var(--size);
border-radius: 6px;
fill: white;
transition: all 0.2s;
@include mobile {
margin: auto 0;
}
}
.close-btn:hover {

View File

@@ -5,18 +5,18 @@
:key="stat.key"
class="stat-card"
:class="{
disabled: stat.value === 0 || loading,
disabled: stat.value === undefined || stat.value === 0 || loading,
unclickable: !!!stat.clickable
}"
@click="
stat.clickable && stat.value > 0 && !loading && handleClick(stat.key)
stat.clickable && stat.value?.total > 0 && !loading && handleClick(stat.key)
"
>
<div class="stat-icon">
<component :is="stat.icon" />
</div>
<div class="stat-content">
<div class="stat-value" v-if="!loading">{{ stat.value }}</div>
<div class="stat-value" v-if="!loading">{{ formatNumber(stat.value?.total) }}</div>
<div class="stat-value loading-dots" v-else>...</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
@@ -26,15 +26,24 @@
<script setup lang="ts">
import { computed } from "vue";
import { formatNumber } from '@/utils'
import IconMovie from "@/icons/IconMovie.vue";
import IconShow from "@/icons/IconShow.vue";
import IconMusic from "@/icons/IconMusic.vue";
import IconClock from "@/icons/IconClock.vue";
interface LibraryStat {
id: number;
title: string;
total: number;
childCount?: number;
leafCount?: number;
}
interface Props {
movies: number;
shows: number;
music: number;
movies: LibraryStat;
shows: LibraryStat;
music: LibraryStat;
watchtime: number;
loading?: boolean;
}
@@ -54,7 +63,7 @@
clickable: true
},
{
key: "shows",
key: "tv shows",
icon: IconShow,
value: props.shows,
label: "TV Shows",

View File

@@ -3,37 +3,14 @@
<div class="plex-details">
<div class="detail-row">
<span class="detail-label">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect>
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path>
</svg>
Server
<IconServer class="label-icon" />
Plex server name
</span>
<span class="detail-value">{{ serverName || "Unknown" }}</span>
</div>
<div class="detail-row">
<span class="detail-label">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="23 4 23 10 17 10"></polyline>
<polyline points="1 20 1 14 7 14"></polyline>
<path
d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"
></path>
</svg>
<IconSync class="label-icon" />
Last Sync
</span>
<span class="detail-value">{{ lastSync || "Never" }}</span>
@@ -42,21 +19,7 @@
<div class="plex-actions">
<seasoned-button @click="$emit('sync')" :disabled="syncing">
<svg
v-if="!syncing"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="23 4 23 10 17 10"></polyline>
<polyline points="1 20 1 14 7 14"></polyline>
<path
d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"
></path>
</svg>
<IconSync v-if="!syncing" class="button-icon" />
{{ syncing ? "Syncing..." : "Sync Library" }}
</seasoned-button>
<seasoned-button @click="$emit('unlink')">
@@ -68,6 +31,8 @@
<script setup lang="ts">
import SeasonedButton from "@/components/ui/SeasonedButton.vue";
import IconServer from "@/icons/IconServer.vue";
import IconSync from "@/icons/IconSync.vue";
interface Props {
serverName: string;
@@ -120,6 +85,11 @@
color: var(--text-color-60);
flex-shrink: 0;
}
.label-icon {
width: 16px;
height: 16px;
}
}
.detail-value {
@@ -147,6 +117,11 @@
svg {
flex-shrink: 0;
}
.button-icon {
width: 16px;
height: 16px;
}
}
}
</style>

View File

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

View File

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

View File

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

10
src/icons/IconPlex.vue Normal file
View File

@@ -0,0 +1,10 @@
<template>
<svg viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg">
<path
d="M16 2c-7.732 0-14 6.268-14 14s6.268 14 14 14 14-6.268 14-14-6.268-14-14-14zM16 28c-6.627 0-12-5.373-12-12s5.373-12 12-12c6.627 0 12 5.373 12 12s-5.373 12-12 12z"
/>
<path
d="M13.333 10.667c-0.368 0-0.667 0.299-0.667 0.667v9.333c0 0.245 0.135 0.469 0.349 0.585 0.215 0.117 0.477 0.104 0.683-0.032l6.667-4.667c0.188-0.131 0.301-0.349 0.301-0.583s-0.113-0.452-0.301-0.583l-6.667-4.667c-0.109-0.076-0.239-0.115-0.365-0.115zM14.667 13.115l4.448 3.115-4.448 3.115v-6.229z"
/>
</svg>
</template>

17
src/icons/IconServer.vue Normal file
View File

@@ -0,0 +1,17 @@
<template>
<svg viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg">
<path
d="M23 10V6c0-0.4719-0.1656-0.9094-0.4406-1.2531v0l-3.6688-4.5594C18.7969 0.0687 18.6531 0 18.5 0h-13C5.35 0 5.2062 0.0687 5.1094 0.1875L1.4406 4.75C1.1656 5.0906 1 5.5281 1 6v4c0 0.5969 0.2625 1.1344 0.6781 1.5C1.2625 11.8656 1 12.4031 1 13v2c0 0.5969 0.2625 1.1344 0.6781 1.5C1.2625 16.8656 1 17.4031 1 18v4c0 1.1031 0.8969 2 2 2h18c1.1031 0 2-0.8969 2-2v-4c0-0.5969-0.2625-1.1344-0.6781-1.5C22.7375 16.1344 23 15.5969 23 15v-2c0-0.5969-0.2625-1.1344-0.6781-1.5C22.7375 11.1344 23 10.5969 23 10zM5.7406 1h12.5219l2.4125 3H3.325zM21 22H3l-31e-4-4c0 0 0 0 31e-4 0v-1h18zM21.0031 15c0 0-31e-4 0 0 0L21 16H3v-1l-31e-4-2c0 0 0 0 31e-4 0v-1h18v1zM3 11V6h18v5z"
/>
<rect width="3" height="1.000008" x="16.999992" y="7.999992" />
<rect width="1.000008" height="1.000008" x="15" y="7.999992" />
<rect width="3" height="1.000008" x="16.999992" y="13.000008" />
<rect width="1.000008" height="1.000008" x="15" y="13.000008" />
<rect width="1.000008" height="1.000008" x="4.000008" y="18" />
<rect width="1.000008" height="1.000008" x="6" y="18" />
<rect width="1.000008" height="1.000008" x="7.999992" y="18" />
<rect width="1.000008" height="1.000008" x="10.000008" y="18" />
<rect width="3" height="1.000008" x="16.999992" y="19.999992" />
<rect width="1.000008" height="1.000008" x="15" y="19.999992" />
</svg>
</template>

16
src/icons/IconSync.vue Normal file
View File

@@ -0,0 +1,16 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="23 4 23 10 17 10"></polyline>
<polyline points="1 20 1 14 7 14"></polyline>
<path
d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"
></path>
</svg>
</template>

View File

@@ -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<UserState, RootState> = {
/* ── Actions ─────────────────────────────────────────────────── */
actions: {
async initUserFromCookie({ dispatch }): Promise<boolean | null> {
const jwtToken = getCookie("authorization");
const jwtToken = getAuthorizationCookie();
if (!jwtToken) return null;
const token = parseJwt(jwtToken);

View File

@@ -0,0 +1,53 @@
<template>
<div class="not-authenticated">
<h1><IconStop /> Must be authenticated with Plex</h1>
<p>Go to Settings to link your Plex account</p>
</div>
</template>
<script setup lang="ts">
import IconStop from "@/icons/IconStop.vue";
</script>
<style lang="scss" scoped>
@import "scss/media-queries";
.not-authenticated {
padding: 2rem;
text-align: center;
h1 {
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
margin-bottom: 1rem;
svg {
margin-right: 1rem;
height: 3rem;
width: 3rem;
}
}
p {
font-size: 1.2rem;
color: var(--text-color-60);
}
@include mobile {
padding: 1rem;
padding-right: 0;
h1 {
font-size: 1.65rem;
svg {
margin-right: 1rem;
height: 2rem;
width: 2rem;
}
}
}
}
</style>

View File

@@ -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"
});
}
}

View File

@@ -1,7 +1,7 @@
export function getLibraryIcon(type: string): string {
const icons: Record<string, string> = {
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<string, string> = {
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<string, string> = {
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;