Refactor: Add library stats, server info, and helper utilities

Extract more reusable components and utilities:

Components:
- PlexLibraryStats.vue: 4-card stats grid with loading states
- PlexServerInfo.vue: Server details and sync/unlink actions

Composables:
- usePlexLibraries.ts: Library data loading and processing logic

Utilities:
- plexHelpers.ts: Pure functions for formatting and calculations
  - getLibraryIcon/Title: Type to display mapping
  - formatDate/MemberSince: Date formatting
  - processLibraryItem: Parse API response to display format
  - calculateGenreStats: Top 5 genres from metadata
  - calculateDuration: Total hours, episodes, tracks

Benefits:
- Cleaner separation: UI vs logic vs utilities
- Testable pure functions
- Reusable across components
- Reduces PlexSettings.vue complexity
This commit is contained in:
2026-02-27 17:40:38 +01:00
parent 1813331673
commit 37ad9ecb7b
4 changed files with 576 additions and 0 deletions

View File

@@ -0,0 +1,178 @@
<template>
<div class="library-stats">
<div
v-for="stat in displayStats"
:key="stat.key"
class="stat-card"
:class="{ disabled: stat.value === 0 || loading }"
@click="
stat.clickable && stat.value > 0 && !loading && handleClick(stat.key)
"
>
<div class="stat-icon">{{ stat.icon }}</div>
<div class="stat-content">
<div class="stat-value" v-if="!loading">{{ stat.value }}</div>
<div class="stat-value loading-dots" v-else>...</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
interface Props {
movies: number;
shows: number;
music: number;
watchtime: number;
loading?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
openLibrary: [type: string];
}>();
const displayStats = computed(() => [
{
key: "movies",
icon: "🎬",
value: props.movies,
label: "Movies",
clickable: true
},
{
key: "shows",
icon: "📺",
value: props.shows,
label: "TV Shows",
clickable: true
},
{
key: "music",
icon: "🎵",
value: props.music,
label: "Albums",
clickable: true
},
{
key: "watchtime",
icon: "⏱️",
value: props.watchtime,
label: "Hours Watched",
clickable: false
}
]);
function handleClick(type: string) {
emit("openLibrary", type);
}
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.library-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.75rem;
margin-bottom: 0.85rem;
@include mobile-only {
grid-template-columns: repeat(2, 1fr);
gap: 0.65rem;
}
}
.stat-card {
background-color: var(--background-ui);
border-radius: 0.5rem;
padding: 1rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid transparent;
@include mobile-only {
padding: 0.85rem 0.75rem;
}
&:hover:not(.disabled) {
background-color: var(--background-40);
border-color: var(--highlight-color);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&.disabled {
cursor: default;
opacity: 0.6;
&:hover {
transform: none;
border-color: transparent;
}
}
}
.stat-icon {
font-size: 2rem;
line-height: 1;
@include mobile-only {
font-size: 1.75rem;
}
}
.stat-content {
text-align: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-color);
line-height: 1;
margin-bottom: 0.25rem;
@include mobile-only {
font-size: 1.3rem;
}
}
.stat-label {
font-size: 0.75rem;
color: var(--text-color-60);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
@include mobile-only {
font-size: 0.7rem;
}
}
.loading-dots {
animation: loadingDots 1.5s infinite;
}
@keyframes loadingDots {
0%,
20% {
opacity: 0.3;
}
50% {
opacity: 1;
}
100% {
opacity: 0.3;
}
}
</style>

View File

@@ -0,0 +1,152 @@
<template>
<div class="plex-server-info">
<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
</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>
Last Sync
</span>
<span class="detail-value">{{ lastSync || "Never" }}</span>
</div>
</div>
<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>
{{ syncing ? "Syncing..." : "Sync Library" }}
</seasoned-button>
<seasoned-button @click="$emit('unlink')">
Unlink Account
</seasoned-button>
</div>
</div>
</template>
<script setup lang="ts">
import SeasonedButton from "@/components/ui/SeasonedButton.vue";
interface Props {
serverName: string;
lastSync: string;
syncing?: boolean;
}
defineProps<Props>();
defineEmits<{
sync: [];
unlink: [];
}>();
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.plex-details {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 0.85rem;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 0.55rem 0.65rem;
background-color: var(--background-ui);
border-radius: 0.25rem;
@include mobile-only {
padding: 0.5rem 0.6rem;
}
}
.detail-label {
font-size: 0.85rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
@include mobile-only {
font-size: 0.8rem;
}
svg {
color: var(--text-color-60);
flex-shrink: 0;
}
}
.detail-value {
font-size: 0.95rem;
@include mobile-only {
font-size: 0.9rem;
}
}
.plex-actions {
display: flex;
gap: 0.65rem;
@include mobile-only {
flex-direction: column;
gap: 0.6rem;
}
button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
svg {
flex-shrink: 0;
}
}
}
</style>

View File

@@ -0,0 +1,120 @@
import { ref } from "vue";
import { usePlexApi } from "./usePlexApi";
import {
processLibraryItem,
calculateGenreStats,
calculateDuration
} from "@/utils/plexHelpers";
export function usePlexLibraries() {
const { plexServerUrl, fetchLibrarySections, fetchLibraryDetails } =
usePlexApi();
const loading = ref(false);
const libraryStats = ref({
movies: 0,
shows: 0,
music: 0,
watchtime: 0
});
const libraryDetails = ref<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 }
});
async function loadLibraries(authToken: string) {
loading.value = true;
// Reset stats
libraryStats.value = { movies: 0, shows: 0, music: 0, watchtime: 0 };
try {
const sections = await fetchLibrarySections(authToken);
for (const section of sections) {
const type = section.type;
const key = section.key;
if (type === "movie") {
await processLibrarySection(authToken, key, "movies");
} else if (type === "show") {
await processLibrarySection(authToken, key, "shows");
} else if (type === "artist") {
await processLibrarySection(authToken, key, "music");
}
}
} catch (error) {
console.error("[PlexLibraries] Error loading libraries:", error);
throw error;
} finally {
loading.value = false;
}
}
async function processLibrarySection(
authToken: string,
sectionKey: string,
libraryType: string
) {
try {
const data = await fetchLibraryDetails(authToken, sectionKey);
if (!data) return;
const totalCount = data.all.MediaContainer?.size || 0;
// Update stats
if (libraryType === "movies") {
libraryStats.value.movies += totalCount;
} else if (libraryType === "shows") {
libraryStats.value.shows += totalCount;
} else if (libraryType === "music") {
libraryStats.value.music += totalCount;
}
// Process recently added items
const recentItems = data.recentMetadata
.slice(0, 5)
.map((item: any) =>
processLibraryItem(item, libraryType, authToken, plexServerUrl.value)
);
// Calculate stats
const genres = calculateGenreStats(data.metadata);
const durations = calculateDuration(data.metadata, libraryType);
// Update library details
libraryDetails.value[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 {
loading,
libraryStats,
libraryDetails,
loadLibraries
};
}

126
src/utils/plexHelpers.ts Normal file
View File

@@ -0,0 +1,126 @@
export function getLibraryIcon(type: string): string {
const icons: Record<string, string> = {
movies: "🎬",
shows: "📺",
music: "🎵"
};
return icons[type] || "📁";
}
export function getLibraryTitle(type: string): string {
const titles: Record<string, string> = {
movies: "Movies",
shows: "TV Shows",
music: "Music"
};
return titles[type] || type;
}
export function formatDate(dateString: string): string {
try {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric"
});
} catch {
return dateString;
}
}
export function formatMemberSince(dateString: string): 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";
}
}
export function processLibraryItem(
item: any,
libraryType: string,
authToken: string,
serverUrl: string
) {
// Get poster/thumbnail URL
let posterUrl = null;
if (item.thumb) {
posterUrl = `${serverUrl}${item.thumb}?X-Plex-Token=${authToken}`;
} else if (item.grandparentThumb) {
posterUrl = `${serverUrl}${item.grandparentThumb}?X-Plex-Token=${authToken}`;
}
const baseItem = {
title: item.title,
year: item.year || item.parentYear || new Date().getFullYear(),
poster: posterUrl,
fallbackIcon: getLibraryIcon(libraryType),
rating: item.rating ? Math.round(item.rating * 10) / 10 : null
};
if (libraryType === "shows") {
return {
...baseItem,
episodes: item.leafCount || 0
};
} else if (libraryType === "music") {
return {
...baseItem,
artist: item.parentTitle || "Unknown Artist",
tracks: item.leafCount || 0
};
}
return baseItem;
}
export function calculateGenreStats(metadata: any[]) {
const genreMap = new Map<string, number>();
metadata.forEach((item: any) => {
if (item.Genre) {
item.Genre.forEach((genre: any) => {
genreMap.set(genre.tag, (genreMap.get(genre.tag) || 0) + 1);
});
}
});
return Array.from(genreMap.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([name, count]) => ({ name, count }));
}
export function calculateDuration(metadata: any[], libraryType: string) {
let totalDuration = 0;
let totalEpisodes = 0;
let totalTracks = 0;
metadata.forEach((item: any) => {
if (item.duration) {
totalDuration += item.duration;
}
if (libraryType === "shows" && item.leafCount) {
totalEpisodes += item.leafCount;
} else if (libraryType === "music" && item.leafCount) {
totalTracks += item.leafCount;
}
});
const hours = Math.round(totalDuration / (1000 * 60 * 60));
const formattedDuration = hours.toLocaleString() + " hours";
return {
totalDuration: formattedDuration,
totalEpisodes,
totalTracks
};
}