mirror of
https://github.com/KevinMidboe/seasoned.git
synced 2026-03-11 11:55:38 +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);
|
const graphCanvas: Ref<HTMLCanvasElement | null> = ref(null);
|
||||||
let graphInstance: Chart | null = null;
|
let graphInstance: Chart | null = null;
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Modern Color System
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
|
|
||||||
const graphTemplates = [
|
const graphTemplates = [
|
||||||
{
|
{
|
||||||
borderColor: "#6366F1",
|
borderColor: "#6366F1",
|
||||||
@@ -77,12 +71,6 @@
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Lifecycle
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
|
|
||||||
onMounted(() => generateGraph());
|
onMounted(() => generateGraph());
|
||||||
watch(() => props.data, generateGraph, { deep: true });
|
watch(() => props.data, generateGraph, { deep: true });
|
||||||
|
|
||||||
@@ -90,12 +78,6 @@
|
|||||||
if (graphInstance) graphInstance.destroy();
|
if (graphInstance) graphInstance.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Helpers
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
|
|
||||||
function removeEmptyDataset(dataset: IGraphDataset) {
|
function removeEmptyDataset(dataset: IGraphDataset) {
|
||||||
return dataset;
|
return dataset;
|
||||||
return !dataset.data.every(point => point === 0);
|
return !dataset.data.every(point => point === 0);
|
||||||
@@ -146,12 +128,6 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Chart Generator
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
|
|
||||||
function generateGraph() {
|
function generateGraph() {
|
||||||
if (!graphCanvas.value) return;
|
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";
|
import { API_HOSTNAME } from "../api";
|
||||||
const TAUTULLI_BASE_URL = "http://plex.schleppe:8181/api/v2";
|
|
||||||
|
|
||||||
interface WatchStats {
|
export interface WatchStats {
|
||||||
totalHours: number;
|
totalHours: number;
|
||||||
totalPlays: number;
|
totalPlays: number;
|
||||||
moviePlays: number;
|
moviePlays: number;
|
||||||
episodePlays: number;
|
episodePlays: number;
|
||||||
musicPlays: number;
|
musicPlays: number;
|
||||||
|
lastWatched: WatchContent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DayStats {
|
interface DayStats {
|
||||||
@@ -29,6 +29,13 @@ interface HomeStatItem {
|
|||||||
media_type?: string;
|
media_type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WatchContent {
|
||||||
|
title: string;
|
||||||
|
plays: number;
|
||||||
|
duration: number;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface PlaysGraphData {
|
interface PlaysGraphData {
|
||||||
categories: string[];
|
categories: string[];
|
||||||
series: {
|
series: {
|
||||||
@@ -37,56 +44,54 @@ interface PlaysGraphData {
|
|||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTautulliStats() {
|
export async function tautulliRequest(
|
||||||
// Helper function to make Tautulli API calls
|
resource: string,
|
||||||
async function tautulliRequest(
|
|
||||||
cmd: string,
|
|
||||||
params: Record<string, any> = {}
|
params: Record<string, any> = {}
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams(params);
|
||||||
apikey: TAUTULLI_API_KEY,
|
const url = new URL(
|
||||||
cmd,
|
`/api/v1/user/stats/${resource}?${queryParams}`,
|
||||||
...params
|
API_HOSTNAME
|
||||||
});
|
);
|
||||||
|
const options: RequestInit = {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
credentials: "include"
|
||||||
|
};
|
||||||
|
|
||||||
const url = `${TAUTULLI_BASE_URL}?${queryParams}`;
|
const resp = await fetch(url, options);
|
||||||
const response = await fetch(url);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!resp.ok) {
|
||||||
throw new Error(`Tautulli API request failed: ${response.statusText}`);
|
throw new Error(`Tautulli API request failed: ${resp.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const response = await resp.json();
|
||||||
if (data.response?.result !== "success") {
|
if (response?.success !== true) {
|
||||||
throw new Error(data.response?.message || "Unknown API error");
|
throw new Error(response?.message || "Unknown API error");
|
||||||
}
|
}
|
||||||
|
|
||||||
return data.response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[Tautulli] Error with ${cmd}:`, error);
|
console.error(`[Tautulli] Error with ${resource}:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch home statistics (pre-aggregated by Tautulli!)
|
// Fetch home statistics (pre-aggregated by Tautulli!)
|
||||||
async function fetchHomeStats(
|
export async function fetchHomeStats(
|
||||||
userId?: number,
|
|
||||||
timeRange = 30,
|
timeRange = 30,
|
||||||
statsType: "plays" | "duration" = "plays"
|
statsType: "plays" | "duration" = "plays"
|
||||||
): Promise<WatchStats> {
|
): Promise<WatchStats> {
|
||||||
try {
|
try {
|
||||||
const params: Record<string, any> = {
|
const params: Record<string, any> = {
|
||||||
time_range: timeRange,
|
days: timeRange,
|
||||||
stats_type: statsType,
|
type: statsType,
|
||||||
grouping: 0
|
grouping: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
if (userId) {
|
const stats = await tautulliRequest("home_stats", params);
|
||||||
params.user_id = userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = await tautulliRequest("get_home_stats", params);
|
|
||||||
|
|
||||||
// Extract stats from the response
|
// Extract stats from the response
|
||||||
let totalPlays = 0;
|
let totalPlays = 0;
|
||||||
@@ -125,8 +130,7 @@ export function useTautulliStats() {
|
|||||||
|
|
||||||
// Calculate total hours from duration
|
// Calculate total hours from duration
|
||||||
if (statsType === "duration") {
|
if (statsType === "duration") {
|
||||||
const totalDuration = [topMovies, topTV, topMusic].reduce(
|
const totalDuration = [topMovies, topTV, topMusic].reduce((sum, stat) => {
|
||||||
(sum, stat) => {
|
|
||||||
if (!stat?.rows) return sum;
|
if (!stat?.rows) return sum;
|
||||||
return (
|
return (
|
||||||
sum +
|
sum +
|
||||||
@@ -135,18 +139,29 @@ export function useTautulliStats() {
|
|||||||
0
|
0
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
},
|
}, 0);
|
||||||
0
|
|
||||||
);
|
|
||||||
totalHours = Math.round(totalDuration / 3600); // Convert seconds to hours
|
totalHours = Math.round(totalDuration / 3600); // Convert seconds to hours
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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"
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalHours,
|
totalHours,
|
||||||
totalPlays,
|
totalPlays,
|
||||||
moviePlays,
|
moviePlays,
|
||||||
episodePlays,
|
episodePlays,
|
||||||
musicPlays
|
musicPlays,
|
||||||
|
lastWatched
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Tautulli] Error fetching home stats:", error);
|
console.error("[Tautulli] Error fetching home stats:", error);
|
||||||
@@ -155,32 +170,25 @@ export function useTautulliStats() {
|
|||||||
totalPlays: 0,
|
totalPlays: 0,
|
||||||
moviePlays: 0,
|
moviePlays: 0,
|
||||||
episodePlays: 0,
|
episodePlays: 0,
|
||||||
musicPlays: 0
|
musicPlays: 0,
|
||||||
|
lastWatched: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch plays by date (already aggregated by Tautulli!)
|
// Fetch plays by date (already aggregated by Tautulli!)
|
||||||
async function fetchPlaysByDate(
|
export async function fetchPlaysByDate(
|
||||||
timeRange = 30,
|
timeRange = 30,
|
||||||
yAxis: "plays" | "duration" = "plays",
|
yAxis: "plays" | "duration" = "plays"
|
||||||
userId?: number
|
): Promise<DayStats[]> {
|
||||||
): Promise<DayStats[]> {
|
|
||||||
try {
|
try {
|
||||||
const params: Record<string, any> = {
|
const params: Record<string, any> = {
|
||||||
time_range: timeRange,
|
days: timeRange,
|
||||||
y_axis: yAxis,
|
y_axis: yAxis,
|
||||||
grouping: 0
|
grouping: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
if (userId) {
|
const data: PlaysGraphData = await tautulliRequest("plays_by_date", params);
|
||||||
params.user_id = userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: PlaysGraphData = await tautulliRequest(
|
|
||||||
"get_plays_by_date",
|
|
||||||
params
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sum all series data for each date
|
// Sum all series data for each date
|
||||||
return data.categories.map((date, index) => {
|
return data.categories.map((date, index) => {
|
||||||
@@ -198,39 +206,33 @@ export function useTautulliStats() {
|
|||||||
console.error("[Tautulli] Error fetching plays by date:", error);
|
console.error("[Tautulli] Error fetching plays by date:", error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch plays by day of week (already aggregated!)
|
// Fetch plays by day of week (already aggregated!)
|
||||||
async function fetchPlaysByDayOfWeek(
|
export async function fetchPlaysByDayOfWeek(
|
||||||
timeRange = 30,
|
timeRange = 30,
|
||||||
yAxis: "plays" | "duration" = "plays",
|
yAxis: "plays" | "duration" = "plays"
|
||||||
userId?: number
|
): Promise<{
|
||||||
): Promise<{
|
|
||||||
labels: string[];
|
labels: string[];
|
||||||
movies: number[];
|
movies: number[];
|
||||||
episodes: number[];
|
episodes: number[];
|
||||||
music: number[];
|
music: number[];
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const params: Record<string, any> = {
|
const params: Record<string, any> = {
|
||||||
time_range: timeRange,
|
days: timeRange,
|
||||||
y_axis: yAxis,
|
y_axis: yAxis,
|
||||||
grouping: 0
|
grouping: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
if (userId) {
|
|
||||||
params.user_id = userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: PlaysGraphData = await tautulliRequest(
|
const data: PlaysGraphData = await tautulliRequest(
|
||||||
"get_plays_by_dayofweek",
|
"plays_by_dayofweek",
|
||||||
params
|
params
|
||||||
);
|
);
|
||||||
|
|
||||||
// Map series names to our expected format
|
// Map series names to our expected format
|
||||||
const movies =
|
const movies =
|
||||||
data.series.find(s => s.name === "Movies")?.data ||
|
data.series.find(s => s.name === "Movies")?.data || new Array(7).fill(0);
|
||||||
new Array(7).fill(0);
|
|
||||||
const episodes =
|
const episodes =
|
||||||
data.series.find(s => s.name === "TV")?.data || new Array(7).fill(0);
|
data.series.find(s => s.name === "TV")?.data || new Array(7).fill(0);
|
||||||
const music =
|
const music =
|
||||||
@@ -259,27 +261,22 @@ export function useTautulliStats() {
|
|||||||
music: new Array(7).fill(0)
|
music: new Array(7).fill(0)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch plays by hour of day (already aggregated!)
|
// Fetch plays by hour of day (already aggregated!)
|
||||||
async function fetchPlaysByHourOfDay(
|
export async function fetchPlaysByHourOfDay(
|
||||||
timeRange = 30,
|
timeRange = 30,
|
||||||
yAxis: "plays" | "duration" = "plays",
|
yAxis: "plays" | "duration" = "plays"
|
||||||
userId?: number
|
): Promise<{ labels: string[]; data: number[] }> {
|
||||||
): Promise<{ labels: string[]; data: number[] }> {
|
|
||||||
try {
|
try {
|
||||||
const params: Record<string, any> = {
|
const params: Record<string, any> = {
|
||||||
time_range: timeRange,
|
days: timeRange,
|
||||||
y_axis: yAxis,
|
y_axis: yAxis,
|
||||||
grouping: 0
|
grouping: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
if (userId) {
|
|
||||||
params.user_id = userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: PlaysGraphData = await tautulliRequest(
|
const data: PlaysGraphData = await tautulliRequest(
|
||||||
"get_plays_by_hourofday",
|
"plays_by_hourofday",
|
||||||
params
|
params
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -299,48 +296,4 @@ export function useTautulliStats() {
|
|||||||
data: new Array(24).fill(0)
|
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
|
|
||||||
};
|
|
||||||
|
|
||||||
if (userId) {
|
|
||||||
params.user_id = userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
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) => ({
|
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="plexUserId && plexUsername" class="activity">
|
<div class="activity">
|
||||||
<h1 class="activity__title">Watch Activity</h1>
|
<h1 class="activity__title">Watch Activity</h1>
|
||||||
|
|
||||||
<!-- Stats Overview -->
|
<!-- Stats Overview -->
|
||||||
<div v-if="watchStats" class="stats-overview">
|
<stats-overview :watch-stats="watchStats" />
|
||||||
<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 class="controls">
|
<div class="controls">
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
@@ -50,7 +33,7 @@
|
|||||||
|
|
||||||
<div class="activity__charts">
|
<div class="activity__charts">
|
||||||
<div class="chart-card">
|
<div class="chart-card">
|
||||||
<h3 class="chart-card__title">Daily Activity</h3>
|
<h3>Daily Activity</h3>
|
||||||
<div class="chart-card__graph">
|
<div class="chart-card__graph">
|
||||||
<Graph
|
<Graph
|
||||||
v-if="playsByDayData"
|
v-if="playsByDayData"
|
||||||
@@ -65,7 +48,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chart-card">
|
<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">
|
<div class="chart-card__graph">
|
||||||
<Graph
|
<Graph
|
||||||
v-if="playsByDayofweekData"
|
v-if="playsByDayofweekData"
|
||||||
@@ -80,7 +63,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chart-card">
|
<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">
|
<div class="chart-card__graph">
|
||||||
<Graph
|
<Graph
|
||||||
v-if="hourlyData"
|
v-if="hourlyData"
|
||||||
@@ -96,80 +79,33 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Top Content -->
|
<!-- Top Content -->
|
||||||
<div v-if="topContent.length > 0" class="activity__top-content">
|
<watch-history :top-content="topContent" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import { useStore } from "vuex";
|
|
||||||
import Graph from "@/components/Graph.vue";
|
import Graph from "@/components/Graph.vue";
|
||||||
import ToggleButton from "@/components/ui/ToggleButton.vue";
|
import ToggleButton from "@/components/ui/ToggleButton.vue";
|
||||||
import IconStop from "@/icons/IconStop.vue";
|
import StatsOverview from "@/components/activity/StatsOverview.vue";
|
||||||
import type { Ref } from "vue";
|
import WatchHistory from "@/components/activity/WatchHistory.vue";
|
||||||
import { useTautulliStats } from "@/composables/useTautulliStats";
|
import {
|
||||||
|
fetchHomeStats,
|
||||||
|
fetchPlaysByDate,
|
||||||
|
fetchPlaysByDayOfWeek,
|
||||||
|
fetchPlaysByHourOfDay
|
||||||
|
} from "../composables/useTautulliStats";
|
||||||
import {
|
import {
|
||||||
GraphTypes,
|
GraphTypes,
|
||||||
GraphValueTypes,
|
GraphValueTypes,
|
||||||
IGraphData
|
IGraphData
|
||||||
} from "../interfaces/IGraph";
|
} from "../interfaces/IGraph";
|
||||||
|
import type { Ref } from "vue";
|
||||||
const store = useStore();
|
import type { WatchStats } from "../composables/useTautulliStats";
|
||||||
|
|
||||||
const days: Ref<number> = ref(30);
|
const days: Ref<number> = ref(30);
|
||||||
const graphViewMode: Ref<GraphTypes> = ref(GraphTypes.Plays);
|
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 = [
|
const graphValueViewMode = [
|
||||||
{
|
{
|
||||||
type: GraphTypes.Plays,
|
type: GraphTypes.Plays,
|
||||||
@@ -193,14 +129,6 @@
|
|||||||
graphValueViewMode.find(viewMode => viewMode.type === graphViewMode.value)
|
graphValueViewMode.find(viewMode => viewMode.type === graphViewMode.value)
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
|
||||||
fetchHomeStats,
|
|
||||||
fetchPlaysByDate,
|
|
||||||
fetchPlaysByDayOfWeek,
|
|
||||||
fetchPlaysByHourOfDay,
|
|
||||||
fetchTopContent
|
|
||||||
} = useTautulliStats();
|
|
||||||
|
|
||||||
function convertDateStringToDayMonth(date: string, short = true): string {
|
function convertDateStringToDayMonth(date: string, short = true): string {
|
||||||
if (!date.match(/[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
|
if (!date.match(/[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
|
||||||
return date;
|
return date;
|
||||||
@@ -210,30 +138,8 @@
|
|||||||
return short ? `${month}.${day}` : `${day}.${month}.${year}`;
|
return short ? `${month}.${day}` : `${day}.${month}.${year}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchChartData() {
|
function activityPerDay(dataPromise: Promise<any>) {
|
||||||
if (!plexUserId.value) return;
|
dataPromise.then(dayData => {
|
||||||
|
|
||||||
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
|
|
||||||
playsByDayData.value = {
|
playsByDayData.value = {
|
||||||
labels: dayData.map(d =>
|
labels: dayData.map(d =>
|
||||||
convertDateStringToDayMonth(d.date, dayData.length < 365)
|
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 = {
|
playsByDayofweekData.value = {
|
||||||
labels: weekData.labels,
|
labels: weekData.labels,
|
||||||
series: [
|
series: [
|
||||||
@@ -258,22 +167,42 @@
|
|||||||
{ name: "Music", data: weekData.music }
|
{ name: "Music", data: weekData.music }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Hourly distribution
|
function hourly(hourlyPromise: Promise<any>) {
|
||||||
|
hourlyPromise.then(hourData => {
|
||||||
hourlyData.value = {
|
hourlyData.value = {
|
||||||
labels: hourData.labels,
|
labels: hourData.labels,
|
||||||
series: [{ name: "Plays", data: hourData.data }]
|
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) {
|
} catch (error) {
|
||||||
console.error("[ActivityPage] Error fetching chart data:", error);
|
console.error("[ActivityPage] Error fetching chart data:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(fetchChartData);
|
||||||
if (plexUsername.value) {
|
|
||||||
fetchChartData();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -297,7 +226,7 @@
|
|||||||
|
|
||||||
@include mobile-only {
|
@include mobile-only {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
margin: 0 0 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,36 +239,7 @@
|
|||||||
gap: 1rem;
|
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 {
|
.chart-card {
|
||||||
background: var(--background-ui);
|
background: var(--background-ui);
|
||||||
@@ -351,7 +251,7 @@
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
h3 {
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
font-weight: 500;
|
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 {
|
.controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
@@ -450,144 +343,4 @@
|
|||||||
color: var(--text-color-60);
|
color: var(--text-color-60);
|
||||||
user-select: none;
|
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user