Feature: Integrate Tautulli stats with enhanced Activity page

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! 📊
This commit is contained in:
2026-02-27 18:10:38 +01:00
parent 6c24bc928c
commit d1578723c4
4 changed files with 470 additions and 40 deletions

View File

@@ -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<string, { plays: number; duration: number }>();
// 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
};
}