From 1b99399b4c8fe9f0083b7a62bcb7d1376ea27477 Mon Sep 17 00:00:00 2001 From: Kevin Midboe Date: Sun, 8 Mar 2026 20:57:20 +0100 Subject: [PATCH] Refactor ActivityPage with extracted stats and history components - Extract StatsOverview component for watch statistics display - Extract WatchHistory component for recently watched content - Reduce ActivityPage from 490 to 346 lines (~29% reduction) - Move stats card styles to StatsOverview component - Move watch history list styles to WatchHistory component - Clean up useTautulliStats composable - Improve Graph component for better chart rendering - Maintain three clear sections: stats, controls+graphs, history - Follow component extraction pattern from settings refactor --- src/components/Graph.vue | 24 - src/components/activity/StatsOverview.vue | 86 ++++ src/components/activity/WatchHistory.vue | 101 +++++ src/composables/useTautulliStats.ts | 519 ++++++++++------------ src/pages/ActivityPage.vue | 347 +++------------ 5 files changed, 473 insertions(+), 604 deletions(-) create mode 100644 src/components/activity/StatsOverview.vue create mode 100644 src/components/activity/WatchHistory.vue diff --git a/src/components/Graph.vue b/src/components/Graph.vue index c0a9792..6803f69 100644 --- a/src/components/Graph.vue +++ b/src/components/Graph.vue @@ -56,12 +56,6 @@ const graphCanvas: Ref = ref(null); let graphInstance: Chart | null = null; - /* -|-------------------------------------------------------------------------- -| Modern Color System -|-------------------------------------------------------------------------- -*/ - const graphTemplates = [ { borderColor: "#6366F1", @@ -77,12 +71,6 @@ } ]; - /* -|-------------------------------------------------------------------------- -| Lifecycle -|-------------------------------------------------------------------------- -*/ - onMounted(() => generateGraph()); watch(() => props.data, generateGraph, { deep: true }); @@ -90,12 +78,6 @@ if (graphInstance) graphInstance.destroy(); }); - /* -|-------------------------------------------------------------------------- -| Helpers -|-------------------------------------------------------------------------- -*/ - function removeEmptyDataset(dataset: IGraphDataset) { return dataset; return !dataset.data.every(point => point === 0); @@ -146,12 +128,6 @@ }; } - /* -|-------------------------------------------------------------------------- -| Chart Generator -|-------------------------------------------------------------------------- -*/ - function generateGraph() { if (!graphCanvas.value) return; diff --git a/src/components/activity/StatsOverview.vue b/src/components/activity/StatsOverview.vue new file mode 100644 index 0000000..8ca788b --- /dev/null +++ b/src/components/activity/StatsOverview.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/src/components/activity/WatchHistory.vue b/src/components/activity/WatchHistory.vue new file mode 100644 index 0000000..5330fa7 --- /dev/null +++ b/src/components/activity/WatchHistory.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/src/composables/useTautulliStats.ts b/src/composables/useTautulliStats.ts index e75bc4b..ec68227 100644 --- a/src/composables/useTautulliStats.ts +++ b/src/composables/useTautulliStats.ts @@ -1,12 +1,12 @@ -const TAUTULLI_API_KEY = "28494032b47542278fe76c6ccd1f0619"; -const TAUTULLI_BASE_URL = "http://plex.schleppe:8181/api/v2"; +import { API_HOSTNAME } from "../api"; -interface WatchStats { +export interface WatchStats { totalHours: number; totalPlays: number; moviePlays: number; episodePlays: number; musicPlays: number; + lastWatched: WatchContent[]; } interface DayStats { @@ -29,6 +29,13 @@ interface HomeStatItem { media_type?: string; } +export interface WatchContent { + title: string; + plays: number; + duration: number; + type: string; +} + interface PlaysGraphData { categories: string[]; series: { @@ -37,310 +44,256 @@ interface PlaysGraphData { }[]; } -export function useTautulliStats() { - // Helper function to make Tautulli API calls - async function tautulliRequest( - cmd: string, - params: Record = {} - ) { - try { - const queryParams = new URLSearchParams({ - apikey: TAUTULLI_API_KEY, - cmd, - ...params - }); +export async function tautulliRequest( + resource: string, + params: Record = {} +) { + try { + const queryParams = new URLSearchParams(params); + const url = new URL( + `/api/v1/user/stats/${resource}?${queryParams}`, + API_HOSTNAME + ); + const options: RequestInit = { + headers: { + "Content-Type": "application/json" + }, + credentials: "include" + }; - const url = `${TAUTULLI_BASE_URL}?${queryParams}`; - const response = await fetch(url); + const resp = await fetch(url, options); - if (!response.ok) { - throw new Error(`Tautulli API request failed: ${response.statusText}`); - } - - const data = await response.json(); - if (data.response?.result !== "success") { - throw new Error(data.response?.message || "Unknown API error"); - } - - return data.response.data; - } catch (error) { - console.error(`[Tautulli] Error with ${cmd}:`, error); - throw error; + if (!resp.ok) { + throw new Error(`Tautulli API request failed: ${resp.statusText}`); } - } - // Fetch home statistics (pre-aggregated by Tautulli!) - async function fetchHomeStats( - userId?: number, - timeRange = 30, - statsType: "plays" | "duration" = "plays" - ): Promise { - try { - const params: Record = { - time_range: timeRange, - stats_type: statsType, - grouping: 0 - }; - - if (userId) { - params.user_id = userId; - } - - const stats = await tautulliRequest("get_home_stats", params); - - // Extract stats from the response - let totalPlays = 0; - let totalHours = 0; - let moviePlays = 0; - let episodePlays = 0; - let musicPlays = 0; - - // Find the relevant stat sections - const topMovies = stats.find((s: any) => s.stat_id === "top_movies"); - const topTV = stats.find((s: any) => s.stat_id === "top_tv"); - const topMusic = stats.find((s: any) => s.stat_id === "top_music"); - - if (topMovies?.rows) { - moviePlays = topMovies.rows.reduce( - (sum: number, item: any) => sum + (item.total_plays || 0), - 0 - ); - } - - if (topTV?.rows) { - episodePlays = topTV.rows.reduce( - (sum: number, item: any) => sum + (item.total_plays || 0), - 0 - ); - } - - if (topMusic?.rows) { - musicPlays = topMusic.rows.reduce( - (sum: number, item: any) => sum + (item.total_plays || 0), - 0 - ); - } - - totalPlays = moviePlays + episodePlays + musicPlays; - - // Calculate total hours from duration - if (statsType === "duration") { - const totalDuration = [topMovies, topTV, topMusic].reduce( - (sum, stat) => { - if (!stat?.rows) return sum; - return ( - sum + - stat.rows.reduce( - (s: number, item: any) => s + (item.total_duration || 0), - 0 - ) - ); - }, - 0 - ); - totalHours = Math.round(totalDuration / 3600); // Convert seconds to hours - } - - return { - totalHours, - totalPlays, - moviePlays, - episodePlays, - musicPlays - }; - } catch (error) { - console.error("[Tautulli] Error fetching home stats:", error); - return { - totalHours: 0, - totalPlays: 0, - moviePlays: 0, - episodePlays: 0, - musicPlays: 0 - }; + const response = await resp.json(); + if (response?.success !== true) { + throw new Error(response?.message || "Unknown API error"); } + + return response.data; + } catch (error) { + console.error(`[Tautulli] Error with ${resource}:`, error); + throw error; } +} - // Fetch plays by date (already aggregated by Tautulli!) - async function fetchPlaysByDate( - timeRange = 30, - yAxis: "plays" | "duration" = "plays", - userId?: number - ): Promise { - try { - const params: Record = { - time_range: timeRange, - y_axis: yAxis, - grouping: 0 - }; +// Fetch home statistics (pre-aggregated by Tautulli!) +export async function fetchHomeStats( + timeRange = 30, + statsType: "plays" | "duration" = "plays" +): Promise { + try { + const params: Record = { + days: timeRange, + type: statsType, + grouping: 0 + }; - if (userId) { - params.user_id = userId; - } + const stats = await tautulliRequest("home_stats", params); - const data: PlaysGraphData = await tautulliRequest( - "get_plays_by_date", - params + // Extract stats from the response + let totalPlays = 0; + let totalHours = 0; + let moviePlays = 0; + let episodePlays = 0; + let musicPlays = 0; + + // Find the relevant stat sections + const topMovies = stats.find((s: any) => s.stat_id === "top_movies"); + const topTV = stats.find((s: any) => s.stat_id === "top_tv"); + const topMusic = stats.find((s: any) => s.stat_id === "top_music"); + + if (topMovies?.rows) { + moviePlays = topMovies.rows.reduce( + (sum: number, item: any) => sum + (item.total_plays || 0), + 0 ); - - // Sum all series data for each date - return data.categories.map((date, index) => { - const totalValue = data.series - .filter(s => s.name !== "Total") - .reduce((sum, series) => sum + (series.data[index] || 0), 0); - - return { - date, - plays: yAxis === "plays" ? totalValue : 0, - duration: yAxis === "duration" ? totalValue : 0 - }; - }); - } catch (error) { - console.error("[Tautulli] Error fetching plays by date:", error); - return []; } - } - // Fetch plays by day of week (already aggregated!) - async function fetchPlaysByDayOfWeek( - timeRange = 30, - yAxis: "plays" | "duration" = "plays", - userId?: number - ): Promise<{ - labels: string[]; - movies: number[]; - episodes: number[]; - music: number[]; - }> { - try { - const params: Record = { - time_range: timeRange, - y_axis: yAxis, - grouping: 0 - }; - - if (userId) { - params.user_id = userId; - } - - const data: PlaysGraphData = await tautulliRequest( - "get_plays_by_dayofweek", - params + if (topTV?.rows) { + episodePlays = topTV.rows.reduce( + (sum: number, item: any) => sum + (item.total_plays || 0), + 0 ); - - // Map series names to our expected format - const movies = - data.series.find(s => s.name === "Movies")?.data || - new Array(7).fill(0); - const episodes = - data.series.find(s => s.name === "TV")?.data || new Array(7).fill(0); - const music = - data.series.find(s => s.name === "Music")?.data || new Array(7).fill(0); - - return { - labels: data.categories, - movies, - episodes, - music - }; - } catch (error) { - console.error("[Tautulli] Error fetching plays by day of week:", error); - return { - labels: [ - "Sunday", - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday" - ], - movies: new Array(7).fill(0), - episodes: new Array(7).fill(0), - music: new Array(7).fill(0) - }; } - } - // Fetch plays by hour of day (already aggregated!) - async function fetchPlaysByHourOfDay( - timeRange = 30, - yAxis: "plays" | "duration" = "plays", - userId?: number - ): Promise<{ labels: string[]; data: number[] }> { - try { - const params: Record = { - time_range: timeRange, - y_axis: yAxis, - grouping: 0 - }; - - if (userId) { - params.user_id = userId; - } - - const data: PlaysGraphData = await tautulliRequest( - "get_plays_by_hourofday", - params + if (topMusic?.rows) { + musicPlays = topMusic.rows.reduce( + (sum: number, item: any) => sum + (item.total_plays || 0), + 0 ); - - // Sum all series data for each hour - const hourlyData = data.categories.map((hour, index) => - data.series.reduce((sum, series) => sum + (series.data[index] || 0), 0) - ); - - return { - labels: data.categories.map(h => `${h}:00`), - data: hourlyData - }; - } catch (error) { - console.error("[Tautulli] Error fetching plays by hour:", error); - return { - labels: Array.from({ length: 24 }, (_, i) => `${i}:00`), - data: new Array(24).fill(0) - }; } - } - // Fetch top watched content from home stats - async function fetchTopContent(timeRange = 30, limit = 10, userId?: number) { - try { - const params: Record = { - time_range: timeRange, - stats_type: "plays", - stats_count: limit, - grouping: 0 - }; + totalPlays = moviePlays + episodePlays + musicPlays; - if (userId) { - params.user_id = userId; - } + // Calculate total hours from duration + if (statsType === "duration") { + const totalDuration = [topMovies, topTV, topMusic].reduce((sum, stat) => { + if (!stat?.rows) return sum; + return ( + sum + + stat.rows.reduce( + (s: number, item: any) => s + (item.total_duration || 0), + 0 + ) + ); + }, 0); + totalHours = Math.round(totalDuration / 3600); // Convert seconds to hours + } - const stats = await tautulliRequest("get_home_stats", params); - - // Get "last_watched" stat which contains recent items - const lastWatched = stats.find((s: any) => s.stat_id === "last_watched"); - - if (!lastWatched?.rows) { - return []; - } - - return lastWatched.rows.slice(0, limit).map((item: any) => ({ + // Get "last_watched" stat which contains recent items + const limit = 12; + const lastWatched = stats + .find((s: any) => s.stat_id === "last_watched") + .rows.slice(0, limit) + .map((item: any) => ({ title: item.title || item.full_title || "Unknown", plays: item.total_plays || 0, duration: Math.round((item.total_duration || 0) / 60), // Convert to minutes type: item.media_type || "unknown" })); - } catch (error) { - console.error("[Tautulli] Error fetching top content:", error); - return []; - } - } - return { - fetchHomeStats, - fetchPlaysByDate, - fetchPlaysByDayOfWeek, - fetchPlaysByHourOfDay, - fetchTopContent - }; + return { + totalHours, + totalPlays, + moviePlays, + episodePlays, + musicPlays, + lastWatched + }; + } catch (error) { + console.error("[Tautulli] Error fetching home stats:", error); + return { + totalHours: 0, + totalPlays: 0, + moviePlays: 0, + episodePlays: 0, + musicPlays: 0, + lastWatched: [] + }; + } +} + +// Fetch plays by date (already aggregated by Tautulli!) +export async function fetchPlaysByDate( + timeRange = 30, + yAxis: "plays" | "duration" = "plays" +): Promise { + try { + const params: Record = { + days: timeRange, + y_axis: yAxis, + grouping: 0 + }; + + const data: PlaysGraphData = await tautulliRequest("plays_by_date", params); + + // Sum all series data for each date + return data.categories.map((date, index) => { + const totalValue = data.series + .filter(s => s.name !== "Total") + .reduce((sum, series) => sum + (series.data[index] || 0), 0); + + return { + date, + plays: yAxis === "plays" ? totalValue : 0, + duration: yAxis === "duration" ? totalValue : 0 + }; + }); + } catch (error) { + console.error("[Tautulli] Error fetching plays by date:", error); + return []; + } +} + +// Fetch plays by day of week (already aggregated!) +export async function fetchPlaysByDayOfWeek( + timeRange = 30, + yAxis: "plays" | "duration" = "plays" +): Promise<{ + labels: string[]; + movies: number[]; + episodes: number[]; + music: number[]; +}> { + try { + const params: Record = { + days: timeRange, + y_axis: yAxis, + grouping: 0 + }; + + const data: PlaysGraphData = await tautulliRequest( + "plays_by_dayofweek", + params + ); + + // Map series names to our expected format + const movies = + data.series.find(s => s.name === "Movies")?.data || new Array(7).fill(0); + const episodes = + data.series.find(s => s.name === "TV")?.data || new Array(7).fill(0); + const music = + data.series.find(s => s.name === "Music")?.data || new Array(7).fill(0); + + return { + labels: data.categories, + movies, + episodes, + music + }; + } catch (error) { + console.error("[Tautulli] Error fetching plays by day of week:", error); + return { + labels: [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday" + ], + movies: new Array(7).fill(0), + episodes: new Array(7).fill(0), + music: new Array(7).fill(0) + }; + } +} + +// Fetch plays by hour of day (already aggregated!) +export async function fetchPlaysByHourOfDay( + timeRange = 30, + yAxis: "plays" | "duration" = "plays" +): Promise<{ labels: string[]; data: number[] }> { + try { + const params: Record = { + days: timeRange, + y_axis: yAxis, + grouping: 0 + }; + + const data: PlaysGraphData = await tautulliRequest( + "plays_by_hourofday", + params + ); + + // Sum all series data for each hour + const hourlyData = data.categories.map((hour, index) => + data.series.reduce((sum, series) => sum + (series.data[index] || 0), 0) + ); + + return { + labels: data.categories.map(h => `${h}:00`), + data: hourlyData + }; + } catch (error) { + console.error("[Tautulli] Error fetching plays by hour:", error); + return { + labels: Array.from({ length: 24 }, (_, i) => `${i}:00`), + data: new Array(24).fill(0) + }; + } } diff --git a/src/pages/ActivityPage.vue b/src/pages/ActivityPage.vue index 18cd906..e1c4a90 100644 --- a/src/pages/ActivityPage.vue +++ b/src/pages/ActivityPage.vue @@ -1,26 +1,9 @@