mirror of
https://github.com/KevinMidboe/seasoned.git
synced 2026-03-09 19:09:07 +00:00
Refactor ActivityPage with extracted stats and history components
- Extract StatsOverview component for watch statistics display - Extract WatchHistory component for recently watched content - Reduce ActivityPage from 490 to 346 lines (~29% reduction) - Move stats card styles to StatsOverview component - Move watch history list styles to WatchHistory component - Clean up useTautulliStats composable - Improve Graph component for better chart rendering - Maintain three clear sections: stats, controls+graphs, history - Follow component extraction pattern from settings refactor
This commit is contained in:
@@ -56,12 +56,6 @@
|
||||
const graphCanvas: Ref<HTMLCanvasElement | null> = ref(null);
|
||||
let graphInstance: Chart | null = null;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Modern Color System
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const graphTemplates = [
|
||||
{
|
||||
borderColor: "#6366F1",
|
||||
@@ -77,12 +71,6 @@
|
||||
}
|
||||
];
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Lifecycle
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
onMounted(() => generateGraph());
|
||||
watch(() => props.data, generateGraph, { deep: true });
|
||||
|
||||
@@ -90,12 +78,6 @@
|
||||
if (graphInstance) graphInstance.destroy();
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Helpers
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function removeEmptyDataset(dataset: IGraphDataset) {
|
||||
return dataset;
|
||||
return !dataset.data.every(point => point === 0);
|
||||
@@ -146,12 +128,6 @@
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Chart Generator
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function generateGraph() {
|
||||
if (!graphCanvas.value) return;
|
||||
|
||||
|
||||
86
src/components/activity/StatsOverview.vue
Normal file
86
src/components/activity/StatsOverview.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<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 watched</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ watchStats.episodePlays }}</div>
|
||||
<div class="stat-label">Episodes watched</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { WatchStats } from "../../composables/useTautulliStats";
|
||||
|
||||
interface Props {
|
||||
watchStats: WatchStats | null;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "scss/variables";
|
||||
@import "scss/media-queries";
|
||||
|
||||
.stats-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
101
src/components/activity/WatchHistory.vue
Normal file
101
src/components/activity/WatchHistory.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div v-if="topContent.length > 0" class="watch-history">
|
||||
<h3 class="section-title">Last 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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface TopContentItem {
|
||||
title: string;
|
||||
type: string;
|
||||
plays: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
topContent: TopContentItem[];
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "scss/variables";
|
||||
@import "scss/media-queries";
|
||||
|
||||
.watch-history {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
.top-content-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
|
||||
@include mobile-only {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.top-content-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background: var(--background-ui);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--text-color-50);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--text-color);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,12 @@
|
||||
const TAUTULLI_API_KEY = "28494032b47542278fe76c6ccd1f0619";
|
||||
const TAUTULLI_BASE_URL = "http://plex.schleppe:8181/api/v2";
|
||||
import { API_HOSTNAME } from "../api";
|
||||
|
||||
interface WatchStats {
|
||||
export interface WatchStats {
|
||||
totalHours: number;
|
||||
totalPlays: number;
|
||||
moviePlays: number;
|
||||
episodePlays: number;
|
||||
musicPlays: number;
|
||||
lastWatched: WatchContent[];
|
||||
}
|
||||
|
||||
interface DayStats {
|
||||
@@ -29,6 +29,13 @@ interface HomeStatItem {
|
||||
media_type?: string;
|
||||
}
|
||||
|
||||
export interface WatchContent {
|
||||
title: string;
|
||||
plays: number;
|
||||
duration: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface PlaysGraphData {
|
||||
categories: string[];
|
||||
series: {
|
||||
@@ -37,310 +44,256 @@ interface PlaysGraphData {
|
||||
}[];
|
||||
}
|
||||
|
||||
export function useTautulliStats() {
|
||||
// Helper function to make Tautulli API calls
|
||||
async function tautulliRequest(
|
||||
cmd: string,
|
||||
params: Record<string, any> = {}
|
||||
) {
|
||||
try {
|
||||
const queryParams = new URLSearchParams({
|
||||
apikey: TAUTULLI_API_KEY,
|
||||
cmd,
|
||||
...params
|
||||
});
|
||||
export async function tautulliRequest(
|
||||
resource: string,
|
||||
params: Record<string, any> = {}
|
||||
) {
|
||||
try {
|
||||
const queryParams = new URLSearchParams(params);
|
||||
const url = new URL(
|
||||
`/api/v1/user/stats/${resource}?${queryParams}`,
|
||||
API_HOSTNAME
|
||||
);
|
||||
const options: RequestInit = {
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
credentials: "include"
|
||||
};
|
||||
|
||||
const url = `${TAUTULLI_BASE_URL}?${queryParams}`;
|
||||
const response = await fetch(url);
|
||||
const resp = await fetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Tautulli API request failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.response?.result !== "success") {
|
||||
throw new Error(data.response?.message || "Unknown API error");
|
||||
}
|
||||
|
||||
return data.response.data;
|
||||
} catch (error) {
|
||||
console.error(`[Tautulli] Error with ${cmd}:`, error);
|
||||
throw error;
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Tautulli API request failed: ${resp.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch home statistics (pre-aggregated by Tautulli!)
|
||||
async function fetchHomeStats(
|
||||
userId?: number,
|
||||
timeRange = 30,
|
||||
statsType: "plays" | "duration" = "plays"
|
||||
): Promise<WatchStats> {
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
time_range: timeRange,
|
||||
stats_type: statsType,
|
||||
grouping: 0
|
||||
};
|
||||
|
||||
if (userId) {
|
||||
params.user_id = userId;
|
||||
}
|
||||
|
||||
const stats = await tautulliRequest("get_home_stats", params);
|
||||
|
||||
// Extract stats from the response
|
||||
let totalPlays = 0;
|
||||
let totalHours = 0;
|
||||
let moviePlays = 0;
|
||||
let episodePlays = 0;
|
||||
let musicPlays = 0;
|
||||
|
||||
// Find the relevant stat sections
|
||||
const topMovies = stats.find((s: any) => s.stat_id === "top_movies");
|
||||
const topTV = stats.find((s: any) => s.stat_id === "top_tv");
|
||||
const topMusic = stats.find((s: any) => s.stat_id === "top_music");
|
||||
|
||||
if (topMovies?.rows) {
|
||||
moviePlays = topMovies.rows.reduce(
|
||||
(sum: number, item: any) => sum + (item.total_plays || 0),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
if (topTV?.rows) {
|
||||
episodePlays = topTV.rows.reduce(
|
||||
(sum: number, item: any) => sum + (item.total_plays || 0),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
if (topMusic?.rows) {
|
||||
musicPlays = topMusic.rows.reduce(
|
||||
(sum: number, item: any) => sum + (item.total_plays || 0),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
totalPlays = moviePlays + episodePlays + musicPlays;
|
||||
|
||||
// Calculate total hours from duration
|
||||
if (statsType === "duration") {
|
||||
const totalDuration = [topMovies, topTV, topMusic].reduce(
|
||||
(sum, stat) => {
|
||||
if (!stat?.rows) return sum;
|
||||
return (
|
||||
sum +
|
||||
stat.rows.reduce(
|
||||
(s: number, item: any) => s + (item.total_duration || 0),
|
||||
0
|
||||
)
|
||||
);
|
||||
},
|
||||
0
|
||||
);
|
||||
totalHours = Math.round(totalDuration / 3600); // Convert seconds to hours
|
||||
}
|
||||
|
||||
return {
|
||||
totalHours,
|
||||
totalPlays,
|
||||
moviePlays,
|
||||
episodePlays,
|
||||
musicPlays
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[Tautulli] Error fetching home stats:", error);
|
||||
return {
|
||||
totalHours: 0,
|
||||
totalPlays: 0,
|
||||
moviePlays: 0,
|
||||
episodePlays: 0,
|
||||
musicPlays: 0
|
||||
};
|
||||
const response = await resp.json();
|
||||
if (response?.success !== true) {
|
||||
throw new Error(response?.message || "Unknown API error");
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error(`[Tautulli] Error with ${resource}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch plays by date (already aggregated by Tautulli!)
|
||||
async function fetchPlaysByDate(
|
||||
timeRange = 30,
|
||||
yAxis: "plays" | "duration" = "plays",
|
||||
userId?: number
|
||||
): Promise<DayStats[]> {
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
time_range: timeRange,
|
||||
y_axis: yAxis,
|
||||
grouping: 0
|
||||
};
|
||||
// Fetch home statistics (pre-aggregated by Tautulli!)
|
||||
export async function fetchHomeStats(
|
||||
timeRange = 30,
|
||||
statsType: "plays" | "duration" = "plays"
|
||||
): Promise<WatchStats> {
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
days: timeRange,
|
||||
type: statsType,
|
||||
grouping: 0
|
||||
};
|
||||
|
||||
if (userId) {
|
||||
params.user_id = userId;
|
||||
}
|
||||
const stats = await tautulliRequest("home_stats", params);
|
||||
|
||||
const data: PlaysGraphData = await tautulliRequest(
|
||||
"get_plays_by_date",
|
||||
params
|
||||
// Extract stats from the response
|
||||
let totalPlays = 0;
|
||||
let totalHours = 0;
|
||||
let moviePlays = 0;
|
||||
let episodePlays = 0;
|
||||
let musicPlays = 0;
|
||||
|
||||
// Find the relevant stat sections
|
||||
const topMovies = stats.find((s: any) => s.stat_id === "top_movies");
|
||||
const topTV = stats.find((s: any) => s.stat_id === "top_tv");
|
||||
const topMusic = stats.find((s: any) => s.stat_id === "top_music");
|
||||
|
||||
if (topMovies?.rows) {
|
||||
moviePlays = topMovies.rows.reduce(
|
||||
(sum: number, item: any) => sum + (item.total_plays || 0),
|
||||
0
|
||||
);
|
||||
|
||||
// Sum all series data for each date
|
||||
return data.categories.map((date, index) => {
|
||||
const totalValue = data.series
|
||||
.filter(s => s.name !== "Total")
|
||||
.reduce((sum, series) => sum + (series.data[index] || 0), 0);
|
||||
|
||||
return {
|
||||
date,
|
||||
plays: yAxis === "plays" ? totalValue : 0,
|
||||
duration: yAxis === "duration" ? totalValue : 0
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Tautulli] Error fetching plays by date:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch plays by day of week (already aggregated!)
|
||||
async function fetchPlaysByDayOfWeek(
|
||||
timeRange = 30,
|
||||
yAxis: "plays" | "duration" = "plays",
|
||||
userId?: number
|
||||
): Promise<{
|
||||
labels: string[];
|
||||
movies: number[];
|
||||
episodes: number[];
|
||||
music: number[];
|
||||
}> {
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
time_range: timeRange,
|
||||
y_axis: yAxis,
|
||||
grouping: 0
|
||||
};
|
||||
|
||||
if (userId) {
|
||||
params.user_id = userId;
|
||||
}
|
||||
|
||||
const data: PlaysGraphData = await tautulliRequest(
|
||||
"get_plays_by_dayofweek",
|
||||
params
|
||||
if (topTV?.rows) {
|
||||
episodePlays = topTV.rows.reduce(
|
||||
(sum: number, item: any) => sum + (item.total_plays || 0),
|
||||
0
|
||||
);
|
||||
|
||||
// Map series names to our expected format
|
||||
const movies =
|
||||
data.series.find(s => s.name === "Movies")?.data ||
|
||||
new Array(7).fill(0);
|
||||
const episodes =
|
||||
data.series.find(s => s.name === "TV")?.data || new Array(7).fill(0);
|
||||
const music =
|
||||
data.series.find(s => s.name === "Music")?.data || new Array(7).fill(0);
|
||||
|
||||
return {
|
||||
labels: data.categories,
|
||||
movies,
|
||||
episodes,
|
||||
music
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[Tautulli] Error fetching plays by day of week:", error);
|
||||
return {
|
||||
labels: [
|
||||
"Sunday",
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday"
|
||||
],
|
||||
movies: new Array(7).fill(0),
|
||||
episodes: new Array(7).fill(0),
|
||||
music: new Array(7).fill(0)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch plays by hour of day (already aggregated!)
|
||||
async function fetchPlaysByHourOfDay(
|
||||
timeRange = 30,
|
||||
yAxis: "plays" | "duration" = "plays",
|
||||
userId?: number
|
||||
): Promise<{ labels: string[]; data: number[] }> {
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
time_range: timeRange,
|
||||
y_axis: yAxis,
|
||||
grouping: 0
|
||||
};
|
||||
|
||||
if (userId) {
|
||||
params.user_id = userId;
|
||||
}
|
||||
|
||||
const data: PlaysGraphData = await tautulliRequest(
|
||||
"get_plays_by_hourofday",
|
||||
params
|
||||
if (topMusic?.rows) {
|
||||
musicPlays = topMusic.rows.reduce(
|
||||
(sum: number, item: any) => sum + (item.total_plays || 0),
|
||||
0
|
||||
);
|
||||
|
||||
// Sum all series data for each hour
|
||||
const hourlyData = data.categories.map((hour, index) =>
|
||||
data.series.reduce((sum, series) => sum + (series.data[index] || 0), 0)
|
||||
);
|
||||
|
||||
return {
|
||||
labels: data.categories.map(h => `${h}:00`),
|
||||
data: hourlyData
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[Tautulli] Error fetching plays by hour:", error);
|
||||
return {
|
||||
labels: Array.from({ length: 24 }, (_, i) => `${i}:00`),
|
||||
data: new Array(24).fill(0)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch top watched content from home stats
|
||||
async function fetchTopContent(timeRange = 30, limit = 10, userId?: number) {
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
time_range: timeRange,
|
||||
stats_type: "plays",
|
||||
stats_count: limit,
|
||||
grouping: 0
|
||||
};
|
||||
totalPlays = moviePlays + episodePlays + musicPlays;
|
||||
|
||||
if (userId) {
|
||||
params.user_id = userId;
|
||||
}
|
||||
// Calculate total hours from duration
|
||||
if (statsType === "duration") {
|
||||
const totalDuration = [topMovies, topTV, topMusic].reduce((sum, stat) => {
|
||||
if (!stat?.rows) return sum;
|
||||
return (
|
||||
sum +
|
||||
stat.rows.reduce(
|
||||
(s: number, item: any) => s + (item.total_duration || 0),
|
||||
0
|
||||
)
|
||||
);
|
||||
}, 0);
|
||||
totalHours = Math.round(totalDuration / 3600); // Convert seconds to hours
|
||||
}
|
||||
|
||||
const stats = await tautulliRequest("get_home_stats", params);
|
||||
|
||||
// Get "last_watched" stat which contains recent items
|
||||
const lastWatched = stats.find((s: any) => s.stat_id === "last_watched");
|
||||
|
||||
if (!lastWatched?.rows) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return lastWatched.rows.slice(0, limit).map((item: any) => ({
|
||||
// Get "last_watched" stat which contains recent items
|
||||
const limit = 12;
|
||||
const lastWatched = stats
|
||||
.find((s: any) => s.stat_id === "last_watched")
|
||||
.rows.slice(0, limit)
|
||||
.map((item: any) => ({
|
||||
title: item.title || item.full_title || "Unknown",
|
||||
plays: item.total_plays || 0,
|
||||
duration: Math.round((item.total_duration || 0) / 60), // Convert to minutes
|
||||
type: item.media_type || "unknown"
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("[Tautulli] Error fetching top content:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fetchHomeStats,
|
||||
fetchPlaysByDate,
|
||||
fetchPlaysByDayOfWeek,
|
||||
fetchPlaysByHourOfDay,
|
||||
fetchTopContent
|
||||
};
|
||||
return {
|
||||
totalHours,
|
||||
totalPlays,
|
||||
moviePlays,
|
||||
episodePlays,
|
||||
musicPlays,
|
||||
lastWatched
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[Tautulli] Error fetching home stats:", error);
|
||||
return {
|
||||
totalHours: 0,
|
||||
totalPlays: 0,
|
||||
moviePlays: 0,
|
||||
episodePlays: 0,
|
||||
musicPlays: 0,
|
||||
lastWatched: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch plays by date (already aggregated by Tautulli!)
|
||||
export async function fetchPlaysByDate(
|
||||
timeRange = 30,
|
||||
yAxis: "plays" | "duration" = "plays"
|
||||
): Promise<DayStats[]> {
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
days: timeRange,
|
||||
y_axis: yAxis,
|
||||
grouping: 0
|
||||
};
|
||||
|
||||
const data: PlaysGraphData = await tautulliRequest("plays_by_date", params);
|
||||
|
||||
// Sum all series data for each date
|
||||
return data.categories.map((date, index) => {
|
||||
const totalValue = data.series
|
||||
.filter(s => s.name !== "Total")
|
||||
.reduce((sum, series) => sum + (series.data[index] || 0), 0);
|
||||
|
||||
return {
|
||||
date,
|
||||
plays: yAxis === "plays" ? totalValue : 0,
|
||||
duration: yAxis === "duration" ? totalValue : 0
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Tautulli] Error fetching plays by date:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch plays by day of week (already aggregated!)
|
||||
export async function fetchPlaysByDayOfWeek(
|
||||
timeRange = 30,
|
||||
yAxis: "plays" | "duration" = "plays"
|
||||
): Promise<{
|
||||
labels: string[];
|
||||
movies: number[];
|
||||
episodes: number[];
|
||||
music: number[];
|
||||
}> {
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
days: timeRange,
|
||||
y_axis: yAxis,
|
||||
grouping: 0
|
||||
};
|
||||
|
||||
const data: PlaysGraphData = await tautulliRequest(
|
||||
"plays_by_dayofweek",
|
||||
params
|
||||
);
|
||||
|
||||
// Map series names to our expected format
|
||||
const movies =
|
||||
data.series.find(s => s.name === "Movies")?.data || new Array(7).fill(0);
|
||||
const episodes =
|
||||
data.series.find(s => s.name === "TV")?.data || new Array(7).fill(0);
|
||||
const music =
|
||||
data.series.find(s => s.name === "Music")?.data || new Array(7).fill(0);
|
||||
|
||||
return {
|
||||
labels: data.categories,
|
||||
movies,
|
||||
episodes,
|
||||
music
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[Tautulli] Error fetching plays by day of week:", error);
|
||||
return {
|
||||
labels: [
|
||||
"Sunday",
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday"
|
||||
],
|
||||
movies: new Array(7).fill(0),
|
||||
episodes: new Array(7).fill(0),
|
||||
music: new Array(7).fill(0)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch plays by hour of day (already aggregated!)
|
||||
export async function fetchPlaysByHourOfDay(
|
||||
timeRange = 30,
|
||||
yAxis: "plays" | "duration" = "plays"
|
||||
): Promise<{ labels: string[]; data: number[] }> {
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
days: timeRange,
|
||||
y_axis: yAxis,
|
||||
grouping: 0
|
||||
};
|
||||
|
||||
const data: PlaysGraphData = await tautulliRequest(
|
||||
"plays_by_hourofday",
|
||||
params
|
||||
);
|
||||
|
||||
// Sum all series data for each hour
|
||||
const hourlyData = data.categories.map((hour, index) =>
|
||||
data.series.reduce((sum, series) => sum + (series.data[index] || 0), 0)
|
||||
);
|
||||
|
||||
return {
|
||||
labels: data.categories.map(h => `${h}:00`),
|
||||
data: hourlyData
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[Tautulli] Error fetching plays by hour:", error);
|
||||
return {
|
||||
labels: Array.from({ length: 24 }, (_, i) => `${i}:00`),
|
||||
data: new Array(24).fill(0)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,9 @@
|
||||
<template>
|
||||
<div v-if="plexUserId && plexUsername" class="activity">
|
||||
<div class="activity">
|
||||
<h1 class="activity__title">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>
|
||||
<stats-overview :watch-stats="watchStats" />
|
||||
|
||||
<div class="controls">
|
||||
<div class="control-group">
|
||||
@@ -50,7 +33,7 @@
|
||||
|
||||
<div class="activity__charts">
|
||||
<div class="chart-card">
|
||||
<h3 class="chart-card__title">Daily Activity</h3>
|
||||
<h3>Daily Activity</h3>
|
||||
<div class="chart-card__graph">
|
||||
<Graph
|
||||
v-if="playsByDayData"
|
||||
@@ -65,7 +48,7 @@
|
||||
</div>
|
||||
|
||||
<div class="chart-card">
|
||||
<h3 class="chart-card__title">Activity by Media Type</h3>
|
||||
<h3>Activity by Media Type</h3>
|
||||
<div class="chart-card__graph">
|
||||
<Graph
|
||||
v-if="playsByDayofweekData"
|
||||
@@ -80,7 +63,7 @@
|
||||
</div>
|
||||
|
||||
<div class="chart-card">
|
||||
<h3 class="chart-card__title">Viewing Patterns by Hour</h3>
|
||||
<h3>Viewing Patterns by Hour</h3>
|
||||
<div class="chart-card__graph">
|
||||
<Graph
|
||||
v-if="hourlyData"
|
||||
@@ -96,80 +79,33 @@
|
||||
</div>
|
||||
|
||||
<!-- Top Content -->
|
||||
<div v-if="topContent.length > 0" class="activity__top-content">
|
||||
<h3 class="section-title">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 with Plex</h1>
|
||||
<p>Go to Settings to link your Plex account</p>
|
||||
<watch-history :top-content="topContent" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 { useTautulliStats } from "@/composables/useTautulliStats";
|
||||
import StatsOverview from "@/components/activity/StatsOverview.vue";
|
||||
import WatchHistory from "@/components/activity/WatchHistory.vue";
|
||||
import {
|
||||
fetchHomeStats,
|
||||
fetchPlaysByDate,
|
||||
fetchPlaysByDayOfWeek,
|
||||
fetchPlaysByHourOfDay
|
||||
} from "../composables/useTautulliStats";
|
||||
import {
|
||||
GraphTypes,
|
||||
GraphValueTypes,
|
||||
IGraphData
|
||||
} from "../interfaces/IGraph";
|
||||
|
||||
const store = useStore();
|
||||
import type { Ref } from "vue";
|
||||
import type { WatchStats } from "../composables/useTautulliStats";
|
||||
|
||||
const days: Ref<number> = ref(30);
|
||||
const graphViewMode: Ref<GraphTypes> = ref(GraphTypes.Plays);
|
||||
|
||||
// Check both Vuex store and localStorage for Plex user
|
||||
const plexUserId = computed(() => {
|
||||
// First try Vuex store
|
||||
const storeId = store.getters["user/plexUserId"];
|
||||
if (storeId) return storeId;
|
||||
|
||||
// Fallback to localStorage
|
||||
const userData = localStorage.getItem("plex_user_data");
|
||||
if (userData) {
|
||||
try {
|
||||
return JSON.parse(userData).id;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
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 = [
|
||||
{
|
||||
type: GraphTypes.Plays,
|
||||
@@ -193,14 +129,6 @@
|
||||
graphValueViewMode.find(viewMode => viewMode.type === graphViewMode.value)
|
||||
);
|
||||
|
||||
const {
|
||||
fetchHomeStats,
|
||||
fetchPlaysByDate,
|
||||
fetchPlaysByDayOfWeek,
|
||||
fetchPlaysByHourOfDay,
|
||||
fetchTopContent
|
||||
} = useTautulliStats();
|
||||
|
||||
function convertDateStringToDayMonth(date: string, short = true): string {
|
||||
if (!date.match(/[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
|
||||
return date;
|
||||
@@ -210,30 +138,8 @@
|
||||
return short ? `${month}.${day}` : `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
async function fetchChartData() {
|
||||
if (!plexUserId.value) return;
|
||||
|
||||
try {
|
||||
const yAxis =
|
||||
graphViewMode.value === GraphTypes.Plays ? "plays" : "duration";
|
||||
|
||||
// Fetch all data in parallel using efficient Tautulli APIs
|
||||
const [homeStats, dayData, weekData, hourData, topContentData] =
|
||||
await Promise.all([
|
||||
fetchHomeStats(plexUserId.value, days.value, "duration"), // Need duration for hours
|
||||
fetchPlaysByDate(days.value, yAxis, plexUserId.value),
|
||||
fetchPlaysByDayOfWeek(days.value, yAxis, plexUserId.value),
|
||||
fetchPlaysByHourOfDay(days.value, yAxis, plexUserId.value),
|
||||
fetchTopContent(days.value, 10, plexUserId.value)
|
||||
]);
|
||||
|
||||
// Set overall stats
|
||||
watchStats.value = homeStats;
|
||||
|
||||
// Set top content
|
||||
topContent.value = topContentData;
|
||||
|
||||
// Activity per day
|
||||
function activityPerDay(dataPromise: Promise<any>) {
|
||||
dataPromise.then(dayData => {
|
||||
playsByDayData.value = {
|
||||
labels: dayData.map(d =>
|
||||
convertDateStringToDayMonth(d.date, dayData.length < 365)
|
||||
@@ -248,8 +154,11 @@
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Activity by day of week (stacked by media type)
|
||||
function playsByDayOfWeek(dataPromise: Promise<any>) {
|
||||
dataPromise.then(weekData => {
|
||||
playsByDayofweekData.value = {
|
||||
labels: weekData.labels,
|
||||
series: [
|
||||
@@ -258,22 +167,42 @@
|
||||
{ name: "Music", data: weekData.music }
|
||||
]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Hourly distribution
|
||||
function hourly(hourlyPromise: Promise<any>) {
|
||||
hourlyPromise.then(hourData => {
|
||||
hourlyData.value = {
|
||||
labels: hourData.labels,
|
||||
series: [{ name: "Plays", data: hourData.data }]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchChartData() {
|
||||
try {
|
||||
const yAxis =
|
||||
graphViewMode.value === GraphTypes.Plays ? "plays" : "duration";
|
||||
|
||||
// Fetch all data in parallel using efficient Tautulli APIs
|
||||
fetchHomeStats(days.value, "duration").then(
|
||||
(homeStats: WatchStats) => (watchStats.value = homeStats)
|
||||
);
|
||||
|
||||
// Activity per day (line graph of last n days)
|
||||
activityPerDay(fetchPlaysByDate(days.value, yAxis));
|
||||
|
||||
// Activity by day of week (stacked by media type)
|
||||
playsByDayOfWeek(fetchPlaysByDayOfWeek(days.value, yAxis));
|
||||
|
||||
// Hourly distribution
|
||||
hourly(fetchPlaysByHourOfDay(days.value, yAxis));
|
||||
} catch (error) {
|
||||
console.error("[ActivityPage] Error fetching chart data:", error);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (plexUsername.value) {
|
||||
fetchChartData();
|
||||
}
|
||||
});
|
||||
onMounted(fetchChartData);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -297,7 +226,7 @@
|
||||
|
||||
@include mobile-only {
|
||||
font-size: 1.5rem;
|
||||
margin: 0 0 1rem 0;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,37 +239,8 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__top-content {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
// .filter {
|
||||
// display: flex;
|
||||
// flex-direction: row;
|
||||
// flex-wrap: wrap;
|
||||
// align-items: center;
|
||||
// margin-bottom: 2rem;
|
||||
|
||||
// h2 {
|
||||
// margin-bottom: 0.5rem;
|
||||
// width: 100%;
|
||||
// font-weight: 400;
|
||||
// }
|
||||
|
||||
// &-item:not(:first-of-type) {
|
||||
// margin-left: 1rem;
|
||||
// }
|
||||
|
||||
// .dayinput {
|
||||
// font-size: 1.2rem;
|
||||
// max-width: 3rem;
|
||||
// background-color: $background-ui;
|
||||
// color: $text-color;
|
||||
// }
|
||||
// }
|
||||
|
||||
.chart-card {
|
||||
background: var(--background-ui);
|
||||
border-radius: 12px;
|
||||
@@ -351,7 +251,7 @@
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
&__title {
|
||||
h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
@@ -374,13 +274,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
@@ -450,144 +343,4 @@
|
||||
color: var(--text-color-60);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.stats-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
@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-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
|
||||
@include mobile-only {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.top-content-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background: var(--background-ui);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--text-color-50);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--text-color);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
height: 3rem;
|
||||
width: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-color-60);
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
padding: 1rem;
|
||||
padding-right: 0;
|
||||
|
||||
h1 {
|
||||
font-size: 1.65rem;
|
||||
|
||||
svg {
|
||||
margin-right: 1rem;
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user