mirror of
https://github.com/KevinMidboe/seasoned.git
synced 2026-03-11 03:49: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