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

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