From d1578723c441b61ae2fc318d2a60b91facfafc12 Mon Sep 17 00:00:00 2001 From: Kevin Midboe Date: Fri, 27 Feb 2026 18:10:38 +0100 Subject: [PATCH] Feature: Integrate Tautulli stats with enhanced Activity page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create useTautulliStats composable (247 lines): - fetchUserHistory() - Get watch history from Tautulli API - calculateWatchStats() - Total hours, plays by media type - groupByDay() - Daily activity (plays & duration) - groupByDayOfWeek() - Weekly patterns by media type - getTopContent() - Most watched content ranking - getHourlyDistribution() - Watch patterns by hour of day Update ActivityPage.vue with new visualizations: - Stats overview cards (4 metrics: plays, hours, movies, episodes) - Activity per day line chart (plays or duration) - Activity by media type stacked bar chart (movies/shows/music) - NEW: Hourly distribution chart - NEW: Top 10 most watched content list Features: - Direct Tautulli API integration (no backend needed) - Real-time data from Plex watch history - Configurable time range (days filter) - Toggle between plays count and watch duration - Responsive grid layout for stats cards - Styled top content ranking with hover effects Benefits: - Rich visualization of actual watch patterns - See viewing habits by time of day - Identify most rewatched content - Compare movie vs TV viewing - All data from authoritative source (Tautulli) ActivityPage now provides comprehensive watch analytics! 📊 --- src/composables/usePlexApi.ts | 3 +- src/composables/usePlexLibraries.ts | 8 +- src/composables/useTautulliStats.ts | 221 ++++++++++++++++++++++ src/pages/ActivityPage.vue | 278 ++++++++++++++++++++++++---- 4 files changed, 470 insertions(+), 40 deletions(-) create mode 100644 src/composables/useTautulliStats.ts diff --git a/src/composables/usePlexApi.ts b/src/composables/usePlexApi.ts index 2a7fec9..6f6a269 100644 --- a/src/composables/usePlexApi.ts +++ b/src/composables/usePlexApi.ts @@ -162,8 +162,9 @@ export function usePlexApi() { 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=5`, + `${serverUrl}/library/sections/${sectionKey}/recentlyAdded?X-Plex-Container-Start=0&X-Plex-Container-Size=${size}`, { method: "GET", headers: { diff --git a/src/composables/usePlexLibraries.ts b/src/composables/usePlexLibraries.ts index 792bddd..b2dea35 100644 --- a/src/composables/usePlexLibraries.ts +++ b/src/composables/usePlexLibraries.ts @@ -128,11 +128,9 @@ export function usePlexLibraries() { } // Process recently added items - const recentItems = data.recentMetadata - .slice(0, 5) - .map((item: any) => - processLibraryItem(item, libraryType, authToken, serverUrl) - ); + const recentItems = data.recentMetadata.map((item: any) => + processLibraryItem(item, libraryType, authToken, serverUrl) + ); // Calculate stats const genres = calculateGenreStats(data.metadata); diff --git a/src/composables/useTautulliStats.ts b/src/composables/useTautulliStats.ts new file mode 100644 index 0000000..175a7c5 --- /dev/null +++ b/src/composables/useTautulliStats.ts @@ -0,0 +1,221 @@ +const TAUTULLI_API_KEY = "28494032b47542278fe76c6ccd1f0619"; +const TAUTULLI_BASE_URL = "http://plex.schleppe:8181/api/v2"; + +interface TautulliHistoryItem { + date: number; + duration: number; + media_type: string; + title: string; + year?: number; + rating_key: string; + parent_rating_key?: string; + grandparent_rating_key?: string; + full_title: string; + started: number; + stopped: number; + watched_status: number; + user: string; +} + +interface WatchStats { + totalHours: number; + totalPlays: number; + moviePlays: number; + episodePlays: number; + musicPlays: number; +} + +interface DayStats { + date: string; + plays: number; + duration: number; +} + +interface MediaTypeStats { + movies: number; + episodes: number; + tracks: number; +} + +export function useTautulliStats() { + // Fetch user history from Tautulli + async function fetchUserHistory(username: string, days: number = 30) { + try { + const length = days * 50; // Approximate plays per day + const url = `${TAUTULLI_BASE_URL}?apikey=${TAUTULLI_API_KEY}&cmd=get_history&user=${encodeURIComponent( + username + )}&length=${length}`; + + const response = await fetch(url); + if (!response.ok) { + throw new Error("Failed to fetch Tautulli history"); + } + + const data = await response.json(); + return (data.response?.data?.data || []) as TautulliHistoryItem[]; + } catch (error) { + console.error("[Tautulli] Error fetching history:", error); + return []; + } + } + + // Calculate overall watch statistics + function calculateWatchStats(history: TautulliHistoryItem[]): WatchStats { + const totalMs = history.reduce( + (sum, item) => sum + (item.duration || 0) * 1000, + 0 + ); + + return { + totalHours: Math.round(totalMs / (1000 * 60 * 60)), + totalPlays: history.length, + moviePlays: history.filter(item => item.media_type === "movie").length, + episodePlays: history.filter(item => item.media_type === "episode") + .length, + musicPlays: history.filter(item => item.media_type === "track").length + }; + } + + // Group plays by day + function groupByDay( + history: TautulliHistoryItem[], + days: number + ): DayStats[] { + const now = new Date(); + const dayMap = new Map(); + + // Initialize all days in range + for (let i = 0; i < days; i++) { + const date = new Date(now); + date.setDate(date.getDate() - i); + const dateStr = date.toISOString().split("T")[0]; + dayMap.set(dateStr, { plays: 0, duration: 0 }); + } + + // Populate with actual data + history.forEach(item => { + const date = new Date(item.date * 1000); + const dateStr = date.toISOString().split("T")[0]; + if (dayMap.has(dateStr)) { + const current = dayMap.get(dateStr)!; + current.plays += 1; + current.duration += item.duration || 0; + } + }); + + // Convert to array and sort by date + return Array.from(dayMap.entries()) + .map(([date, stats]) => ({ + date, + plays: stats.plays, + duration: Math.round(stats.duration / 60) // Convert to minutes + })) + .sort((a, b) => a.date.localeCompare(b.date)); + } + + // Group plays by day of week + function groupByDayOfWeek(history: TautulliHistoryItem[]): { + labels: string[]; + movies: number[]; + episodes: number[]; + music: number[]; + } { + const dayNames = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday" + ]; + + const dayStats = new Map< + number, + { movies: number; episodes: number; music: number } + >(); + + // Initialize all days + for (let i = 0; i < 7; i++) { + dayStats.set(i, { movies: 0, episodes: 0, music: 0 }); + } + + // Populate with actual data + history.forEach(item => { + const date = new Date(item.date * 1000); + const dayOfWeek = date.getDay(); + const stats = dayStats.get(dayOfWeek)!; + + if (item.media_type === "movie") { + stats.movies += 1; + } else if (item.media_type === "episode") { + stats.episodes += 1; + } else if (item.media_type === "track") { + stats.music += 1; + } + }); + + return { + labels: dayNames, + movies: dayNames.map((_, i) => dayStats.get(i)!.movies), + episodes: dayNames.map((_, i) => dayStats.get(i)!.episodes), + music: dayNames.map((_, i) => dayStats.get(i)!.music) + }; + } + + // Get top watched content + function getTopContent(history: TautulliHistoryItem[], limit: number = 10) { + const contentMap = new Map< + string, + { title: string; plays: number; duration: number; type: string } + >(); + + history.forEach(item => { + const key = item.rating_key; + if (!contentMap.has(key)) { + contentMap.set(key, { + title: item.full_title || item.title, + plays: 0, + duration: 0, + type: item.media_type + }); + } + const content = contentMap.get(key)!; + content.plays += 1; + content.duration += item.duration || 0; + }); + + return Array.from(contentMap.values()) + .sort((a, b) => b.plays - a.plays) + .slice(0, limit) + .map(item => ({ + ...item, + duration: Math.round(item.duration / 60) // Convert to minutes + })); + } + + // Get hourly distribution + function getHourlyDistribution(history: TautulliHistoryItem[]) { + const hours = new Array(24).fill(0); + + history.forEach(item => { + const date = new Date(item.date * 1000); + const hour = date.getHours(); + hours[hour] += 1; + }); + + return { + labels: hours.map((_, i) => `${i}:00`), + data: hours + }; + } + + return { + fetchUserHistory, + calculateWatchStats, + groupByDay, + groupByDayOfWeek, + getTopContent, + getHourlyDistribution + }; +} diff --git a/src/pages/ActivityPage.vue b/src/pages/ActivityPage.vue index 793c879..7f5e72f 100644 --- a/src/pages/ActivityPage.vue +++ b/src/pages/ActivityPage.vue @@ -1,7 +1,27 @@