mirror of
https://github.com/KevinMidboe/seasoned.git
synced 2026-03-10 11:29:07 +00:00
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:
178
src/components/plex/PlexLibraryStats.vue
Normal file
178
src/components/plex/PlexLibraryStats.vue
Normal 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>
|
||||
152
src/components/plex/PlexServerInfo.vue
Normal file
152
src/components/plex/PlexServerInfo.vue
Normal 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>
|
||||
120
src/composables/usePlexLibraries.ts
Normal file
120
src/composables/usePlexLibraries.ts
Normal 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
126
src/utils/plexHelpers.ts
Normal 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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user