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

@@ -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: {

View File

@@ -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);

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
};
}

View File

@@ -1,7 +1,27 @@
<template>
<div v-if="plexUserId" class="wrapper">
<div v-if="plexUserId && plexUsername" class="wrapper">
<h1>Your watch activity</h1>
<!-- Stats Overview -->
<div v-if="watchStats" class="stats-overview">
<div class="stat-card">
<div class="stat-value">{{ watchStats.totalPlays }}</div>
<div class="stat-label">Total Plays</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ watchStats.totalHours }}h</div>
<div class="stat-label">Watch Time</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ watchStats.moviePlays }}</div>
<div class="stat-label">Movies</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ watchStats.episodePlays }}</div>
<div class="stat-label">Episodes</div>
</div>
</div>
<div style="display: flex; flex-direction: row">
<label class="filter" for="dayinput">
<span>Days:</span>
@@ -41,7 +61,7 @@
/>
</div>
<h3 class="chart-header">Activity per day of week:</h3>
<h3 class="chart-header">Activity by media type:</h3>
<div class="graph">
<Graph
v-if="playsByDayofweekData"
@@ -49,25 +69,59 @@
type="bar"
:stacked="true"
:dataset-description-suffix="`watch last ${days} days`"
:tooltip-description-suffix="selectedGraphViewMode.tooltipLabel"
:graph-value-type="selectedGraphViewMode.valueType"
tooltip-description-suffix="plays"
:graph-value-type="GraphValueTypes.Number"
/>
</div>
<h3 class="chart-header">Watch time by hour:</h3>
<div class="graph">
<Graph
v-if="hourlyData"
:data="hourlyData"
type="bar"
:stacked="false"
:dataset-description-suffix="`last ${days} days`"
tooltip-description-suffix="plays"
:graph-value-type="GraphValueTypes.Number"
/>
</div>
</div>
<!-- Top Content -->
<div v-if="topContent.length > 0" class="top-content-section">
<h3 class="chart-header">Most watched:</h3>
<div class="top-content-list">
<div
v-for="(item, index) in topContent"
:key="index"
class="top-content-item"
>
<div class="content-rank">{{ index + 1 }}</div>
<div class="content-details">
<div class="content-title">{{ item.title }}</div>
<div class="content-meta">
{{ item.type }} {{ item.plays }} plays {{ item.duration }}min
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else class="not-authenticated">
<h1><IconStop /> Must be authenticated</h1>
<h1><IconStop /> Must be authenticated with Plex</h1>
<p>Go to Settings to link your Plex account</p>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { ref, computed, onMounted } from "vue";
import { useStore } from "vuex";
import Graph from "@/components/Graph.vue";
import ToggleButton from "@/components/ui/ToggleButton.vue";
import IconStop from "@/icons/IconStop.vue";
import type { Ref } from "vue";
import { fetchGraphData } from "../api";
import { useTautulliStats } from "@/composables/useTautulliStats";
import {
GraphTypes,
GraphValueTypes,
@@ -79,6 +133,17 @@
const days: Ref<number> = ref(30);
const graphViewMode: Ref<GraphTypes> = ref(GraphTypes.Plays);
const plexUserId = computed(() => store.getters["user/plexUserId"]);
const plexUsername = computed(() => {
const userData = localStorage.getItem("plex_user_data");
if (userData) {
try {
return JSON.parse(userData).username;
} catch {
return null;
}
}
return null;
});
const graphValueViewMode = [
{
@@ -95,11 +160,23 @@
const playsByDayData: Ref<IGraphData> = ref(null);
const playsByDayofweekData: Ref<IGraphData> = ref(null);
const hourlyData: Ref<IGraphData> = ref(null);
const watchStats = ref(null);
const topContent = ref([]);
const selectedGraphViewMode = computed(() =>
graphValueViewMode.find(viewMode => viewMode.type === graphViewMode.value)
);
const {
fetchUserHistory,
calculateWatchStats,
groupByDay,
groupByDayOfWeek,
getTopContent,
getHourlyDistribution
} = useTautulliStats();
function convertDateStringToDayMonth(date: string): string {
if (!date.match(/[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
return date;
@@ -109,35 +186,60 @@
return `${day}.${month}`;
}
function convertDateLabels(data) {
return {
labels: data.categories.map(convertDateStringToDayMonth),
series: data.series
};
async function fetchChartData() {
if (!plexUsername.value) return;
try {
const history = await fetchUserHistory(plexUsername.value, days.value);
// Calculate overall stats
watchStats.value = calculateWatchStats(history);
// Get top content
topContent.value = getTopContent(history, 10);
// Activity per day
const dayData = groupByDay(history, days.value);
playsByDayData.value = {
labels: dayData.map(d => convertDateStringToDayMonth(d.date)),
series: [
{
name: "Activity",
data:
graphViewMode.value === GraphTypes.Plays
? dayData.map(d => d.plays)
: dayData.map(d => d.duration)
}
]
};
// Activity by day of week (stacked by media type)
const weekData = groupByDayOfWeek(history);
playsByDayofweekData.value = {
labels: weekData.labels,
series: [
{ name: "Movies", data: weekData.movies },
{ name: "Episodes", data: weekData.episodes },
{ name: "Music", data: weekData.music }
]
};
// Hourly distribution
const hourData = getHourlyDistribution(history);
hourlyData.value = {
labels: hourData.labels,
series: [{ name: "Plays", data: hourData.data }]
};
} catch (error) {
console.error("[ActivityPage] Error fetching chart data:", error);
}
}
async function fetchPlaysByDay() {
playsByDayData.value = await fetchGraphData(
"plays_by_day",
days.value,
graphViewMode.value
).then(data => convertDateLabels(data?.data));
}
async function fetchPlaysByDayOfWeek() {
playsByDayofweekData.value = await fetchGraphData(
"plays_by_dayofweek",
days.value,
graphViewMode.value
).then(data => convertDateLabels(data?.data));
}
function fetchChartData() {
fetchPlaysByDay();
fetchPlaysByDayOfWeek();
}
fetchChartData();
onMounted(() => {
if (plexUsername.value) {
fetchChartData();
}
});
</script>
<style lang="scss" scoped>
@@ -218,13 +320,115 @@
}
}
.stats-overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1.5rem;
margin: 2rem 0;
@include mobile-only {
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
}
.stat-card {
background: var(--background-ui);
padding: 1.5rem;
border-radius: 12px;
text-align: center;
transition: transform 0.2s;
&:hover {
transform: translateY(-4px);
}
@include mobile-only {
padding: 1rem;
}
}
.stat-value {
font-size: 2.5rem;
font-weight: 700;
color: var(--highlight-color);
margin-bottom: 0.5rem;
@include mobile-only {
font-size: 2rem;
}
}
.stat-label {
font-size: 0.9rem;
color: var(--text-color-60);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 300;
@include mobile-only {
font-size: 0.8rem;
}
}
.top-content-section {
margin-top: 3rem;
}
.top-content-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.top-content-item {
display: flex;
align-items: center;
gap: 1rem;
background: var(--background-ui);
padding: 1rem;
border-radius: 8px;
transition: background 0.2s;
&:hover {
background: var(--background-40);
}
}
.content-rank {
font-size: 1.5rem;
font-weight: 700;
color: var(--highlight-color);
min-width: 2.5rem;
text-align: center;
}
.content-details {
flex: 1;
}
.content-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-color);
margin-bottom: 0.25rem;
}
.content-meta {
font-size: 0.85rem;
color: var(--text-color-60);
}
.not-authenticated {
padding: 2rem;
text-align: center;
h1 {
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
margin-bottom: 1rem;
svg {
margin-right: 1rem;
@@ -232,6 +436,12 @@
width: 3rem;
}
}
p {
font-size: 1.2rem;
color: var(--text-color-60);
}
@include mobile {
padding: 1rem;
padding-right: 0;