mirror of
https://github.com/KevinMidboe/seasoned.git
synced 2026-03-11 03:49:07 +00:00
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:
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
221
src/composables/useTautulliStats.ts
Normal file
221
src/composables/useTautulliStats.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user