diff --git a/injector.js b/injector.js index a8ebf00..ba74c98 100644 --- a/injector.js +++ b/injector.js @@ -184,6 +184,12 @@ css: [ 'thirdparty/pickr/pickr-nano.min.css' ] + }, + chartjs: { + js: [ + 'thirdparty/chartjs/chart.js' + ], + css: [] } }; @@ -500,11 +506,19 @@ dependencies: [], description: 'Toast notification system using Jellyfin\'s existing toast functionality' }, + { + name: 'statistics', + script: 'statistics.js', + css: null, + dependencies: [], + description: 'Watchlist statistics charts and activity analytics' + }, { name: 'watchlist', script: 'watchlist.js', css: 'watchlist.css', - dependencies: ['cardBuilder', 'localStorageCache', 'modal', 'utils'], + dependencies: ['cardBuilder', 'localStorageCache', 'modal', 'utils', 'statistics'], + thirdParty: ['chartjs'], description: 'Adds watchlist functionality throughout Jellyfin interface' }, { diff --git a/scripts/kefinTweaks-loader.js b/scripts/kefinTweaks-loader.js index d87a4c5..c7f80d2 100644 --- a/scripts/kefinTweaks-loader.js +++ b/scripts/kefinTweaks-loader.js @@ -42,6 +42,12 @@ css: [ 'thirdparty/pickr/pickr-nano.min.css' ] + }, + chartjs: { + js: [ + 'thirdparty/chartjs/chart.js' + ], + css: [] } }; @@ -364,11 +370,19 @@ dependencies: [], description: 'Toast notification system using Jellyfin\'s existing toast functionality' }, + { + name: 'statistics', + script: 'statistics.js', + css: null, + dependencies: [], + description: 'Watchlist statistics charts and activity analytics' + }, { name: 'watchlist', script: 'watchlist.js', css: 'watchlist.css', - dependencies: ['cardBuilder', 'localStorageCache', 'modal', 'utils'], + dependencies: ['cardBuilder', 'localStorageCache', 'modal', 'utils', 'statistics'], + thirdParty: ['chartjs'], description: 'Adds watchlist functionality throughout Jellyfin interface' }, { diff --git a/scripts/statistics.js b/scripts/statistics.js new file mode 100644 index 0000000..83e042d --- /dev/null +++ b/scripts/statistics.js @@ -0,0 +1,1001 @@ +// KefinTweaks Watchlist Statistics — activity charts + lists (Chart.js) +(function () { + 'use strict'; + + const LOG = (...args) => console.log('[KefinTweaks Statistics]', ...args); + const WARN = (...args) => console.warn('[KefinTweaks Statistics]', ...args); + + // Tunable thresholds (ms / ticks) + const BULK_MARK_WINDOW_MS = 60 * 1000; // ±1 minute consecutive cluster + const BINGE_MAX_SPAN_MS = 12 * 60 * 60 * 1000; // 12 hours watch span + const BINGE_MIN_RUNTIME_MS = 4 * 60 * 60 * 1000; // 4 hours of content + const BINGE_MAX_IDLE_GAP_MS = 4 * 60 * 60 * 1000; // max gap between end of one and start of next + const TICKS_PER_MS = 10000; // 1 tick = 100 ns → 10_000 ticks per ms + const BINGE_MIN_RUNTIME_TICKS = BINGE_MIN_RUNTIME_MS * TICKS_PER_MS; + const FALLBACK_MOVIE_RUNTIME_TICKS = 2 * 60 * 60 * 1000 * TICKS_PER_MS; // 2h + const FALLBACK_EPISODE_RUNTIME_TICKS = 1 * 60 * 60 * 1000 * TICKS_PER_MS; // 1h + const MIN_STREAK_DAYS = 2; + const MAX_STREAKS_SHOWN = 10; + const MAX_BINGES_SHOWN = 10; + const MAX_FAVORITES_SHOWN = 12; + + const ACTIVITY_RANGES = [ + { key: '30d', label: '30d', days: 30 }, + { key: '90d', label: '90d', days: 90 }, + { key: '1y', label: '1y', days: 365 }, + { key: 'all', label: 'All', days: null } + ]; + const DEFAULT_ACTIVITY_RANGE = '30d'; + + let selectedActivityRange = DEFAULT_ACTIVITY_RANGE; + let lastActivityEvents = []; + let overTimeChartRoot = null; + + const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const MONTH_LABELS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + + const CHART_COLORS = { + primary: 'rgba(100, 181, 246, 0.85)', + primaryFill: 'rgba(100, 181, 246, 0.25)', + grid: 'rgba(255, 255, 255, 0.08)', + text: 'rgba(255, 255, 255, 0.7)' + }; + + /** @type {import('chart.js').Chart[]} */ + let chartInstances = []; + + function ticksToMs(ticks) { + return (Number(ticks) || 0) / TICKS_PER_MS; + } + + function effectiveRunTimeTicks(event) { + const ticks = Number(event.runTimeTicks) || 0; + if (ticks > 0) return ticks; + return event.type === 'Movie' ? FALLBACK_MOVIE_RUNTIME_TICKS : FALLBACK_EPISODE_RUNTIME_TICKS; + } + + function localDayKey(date) { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; + } + + function parseLocalDayKey(key) { + const [y, m, d] = key.split('-').map(Number); + return new Date(y, m - 1, d); + } + + function formatDayKey(key) { + const dt = parseLocalDayKey(key); + return dt.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); + } + + function formatHours(ticks) { + const hours = ticksToMs(ticks) / (60 * 60 * 1000); + return `${hours.toFixed(1)} hours`; + } + + function formatHoursValue(hours) { + return `${Number(hours).toFixed(1)} hours`; + } + + function formatDayMonth(date) { + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + } + + function formatTimeOnly(date) { + return date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); + } + + function mediaTypeIcon(type) { + if (type === 'Movie') return 'movie'; + if (type === 'Series') return 'tv'; + return 'tv'; // Episode + } + + function buildRawEvents(progressList, movies) { + const events = []; + + (movies || []).forEach((movie) => { + const key = movie?.UserData?.LastPlayedDate; + if (!key) return; + const at = new Date(key); + if (Number.isNaN(at.getTime())) return; + events.push({ + at, + itemId: movie.Id, + name: movie.Name || 'Movie', + type: 'Movie', + lastPlayedKey: String(key), + runTimeTicks: movie.RunTimeTicks || 0 + }); + }); + + (progressList || []).forEach((progress) => { + const seriesId = progress?.series?.Id; + const seriesName = progress?.series?.Name || ''; + const source = Array.isArray(progress.episodes) && progress.episodes.length + ? progress.episodes + : (progress.playEvents || []); + + source.forEach((ep) => { + const key = ep?.UserData?.LastPlayedDate || ep?.LastPlayedDate; + const played = ep?.UserData ? ep.UserData.Played === true : !!key; + if (!played || !key) return; + const at = new Date(key); + if (Number.isNaN(at.getTime())) return; + events.push({ + at, + itemId: ep.Id, + name: ep.Name || 'Episode', + type: 'Episode', + seriesId, + seriesName, + season: ep.ParentIndexNumber != null ? ep.ParentIndexNumber : null, + episode: ep.IndexNumber != null ? ep.IndexNumber : null, + lastPlayedKey: String(key), + runTimeTicks: ep.RunTimeTicks || 0 + }); + }); + }); + + events.sort((a, b) => a.at - b.at); + return events; + } + + /** + * Drop clusters of 2+ events within BULK_MARK_WINDOW_MS of consecutive neighbors. + */ + function stripBulkMarkClusters(events) { + if (!events.length) return []; + const sorted = events.slice().sort((a, b) => a.at - b.at); + const kept = []; + let cluster = [sorted[0]]; + + const flush = () => { + if (cluster.length === 1) kept.push(cluster[0]); + // size >= 2 → bulk mark anomaly, discard + cluster = []; + }; + + for (let i = 1; i < sorted.length; i++) { + const prev = cluster[cluster.length - 1]; + const cur = sorted[i]; + if (cur.at - prev.at <= BULK_MARK_WINDOW_MS) { + cluster.push(cur); + } else { + flush(); + cluster = [cur]; + } + } + flush(); + return kept; + } + + function startOfLocalDay(date) { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + return d; + } + + function startOfLocalWeek(date) { + const d = startOfLocalDay(date); + d.setDate(d.getDate() - d.getDay()); // Sunday start + return d; + } + + function startOfLocalMonth(date) { + return new Date(date.getFullYear(), date.getMonth(), 1); + } + + function addDays(date, n) { + const d = new Date(date); + d.setDate(d.getDate() + n); + return d; + } + + function resolveActivityRange(rangeKey) { + const def = ACTIVITY_RANGES.find((r) => r.key === rangeKey) || ACTIVITY_RANGES[0]; + const end = startOfLocalDay(new Date()); + if (def.days == null) { + return { key: def.key, start: null, end, label: def.label }; + } + return { + key: def.key, + start: addDays(end, -(def.days - 1)), + end, + label: def.label + }; + } + + function filterEventsByRange(events, range) { + if (!range.start) return events.slice(); + const startMs = range.start.getTime(); + const endMs = range.end.getTime() + (24 * 60 * 60 * 1000) - 1; + return events.filter((e) => { + const t = e.at.getTime(); + return t >= startMs && t <= endMs; + }); + } + + /** + * Adaptive buckets: ≤90d daily, ≤2y weekly, else monthly. + */ + function resolveBucket(rangeKey, filteredEvents, rangeEnd) { + const def = ACTIVITY_RANGES.find((r) => r.key === rangeKey); + let spanDays; + if (def?.days != null) { + spanDays = def.days; + } else if (filteredEvents.length) { + const oldest = filteredEvents.reduce((min, e) => (e.at < min ? e.at : min), filteredEvents[0].at); + spanDays = Math.max(1, Math.round((rangeEnd - startOfLocalDay(oldest)) / (24 * 60 * 60 * 1000)) + 1); + } else { + spanDays = 30; + } + if (spanDays <= 90) return 'day'; + if (spanDays <= 730) return 'week'; + return 'month'; + } + + function bucketStart(date, bucket) { + if (bucket === 'week') return startOfLocalWeek(date); + if (bucket === 'month') return startOfLocalMonth(date); + return startOfLocalDay(date); + } + + function nextBucket(date, bucket) { + if (bucket === 'week') return addDays(date, 7); + if (bucket === 'month') return new Date(date.getFullYear(), date.getMonth() + 1, 1); + return addDays(date, 1); + } + + function formatBucketLabel(date, bucket) { + if (bucket === 'month') { + return date.toLocaleDateString(undefined, { month: 'short', year: 'numeric' }); + } + if (bucket === 'week') { + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); + } + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); + } + + function bucketSubtitle(bucket) { + if (bucket === 'week') return 'Items by last played week'; + if (bucket === 'month') return 'Items by last played month'; + return 'Items by last played day'; + } + + function buildPlaysOverTimeSeries(events, rangeKey = selectedActivityRange) { + const range = resolveActivityRange(rangeKey); + const filtered = filterEventsByRange(events, range); + const bucket = resolveBucket(range.key, filtered, range.end); + + if (!filtered.length && !range.start) { + return { labels: [], values: [], bucket, rangeKey: range.key }; + } + + const end = range.end; + let start = range.start; + if (!start) { + if (!filtered.length) { + return { labels: [], values: [], bucket, rangeKey: range.key }; + } + const oldest = filtered.reduce((min, e) => (e.at < min ? e.at : min), filtered[0].at); + start = bucketStart(oldest, bucket); + } else { + start = bucketStart(start, bucket); + } + + const counts = Object.create(null); + filtered.forEach((e) => { + const key = localDayKey(bucketStart(e.at, bucket)); + counts[key] = (counts[key] || 0) + 1; + }); + + const labels = []; + const values = []; + let cursor = new Date(start); + const endBucket = bucketStart(end, bucket); + while (cursor <= endBucket) { + const key = localDayKey(cursor); + labels.push(formatBucketLabel(cursor, bucket)); + values.push(counts[key] || 0); + cursor = nextBucket(cursor, bucket); + } + + return { labels, values, bucket, rangeKey: range.key }; + } + + function buildWeekdayCounts(events) { + const values = new Array(7).fill(0); + events.forEach((e) => { + values[e.at.getDay()] += 1; + }); + return values; + } + + function buildHourCounts(events) { + const values = new Array(24).fill(0); + events.forEach((e) => { + values[e.at.getHours()] += 1; + }); + return values; + } + + function hourLabel(h) { + if (h === 0) return '12am'; + if (h === 12) return '12pm'; + if (h < 12) return `${h}am`; + return `${h - 12}pm`; + } + + function buildMonthCounts(events) { + const values = new Array(12).fill(0); + events.forEach((e) => { + values[e.at.getMonth()] += 1; + }); + return values; + } + + function formatSeasonEpisode(season, episode) { + if (season == null || episode == null) return null; + const e = String(Number(episode)).padStart(2, '0'); + return `${Number(season)}x${e}`; + } + + function eventDisplayName(e) { + if (e.type === 'Episode' || e.seriesName) { + const se = formatSeasonEpisode(e.season, e.episode); + const series = e.seriesName || 'Series'; + const epName = e.name || 'Episode'; + if (se) return `${series} - ${se} - ${epName}`; + return `${series} - ${epName}`; + } + return e.name; + } + + function formatDurationShort(ticks) { + const ms = ticksToMs(ticks); + const minutes = Math.round(ms / 60000); + if (minutes < 60) return `${minutes}m`; + const h = Math.floor(minutes / 60); + const m = minutes % 60; + return m ? `${h}h ${m}m` : `${h}h`; + } + + function computeStreaks(events) { + if (!events.length) return []; + const byDay = Object.create(null); + events.forEach((e) => { + const key = localDayKey(e.at); + if (!byDay[key]) byDay[key] = []; + byDay[key].push(e); + }); + const days = Object.keys(byDay).sort(); + const streaks = []; + let start = days[0]; + let prev = days[0]; + let len = 1; + + const flush = (endKey, length, startKey) => { + if (length < MIN_STREAK_DAYS) return; + const dayKeys = []; + const cursor = parseLocalDayKey(startKey); + const endDate = parseLocalDayKey(endKey); + while (cursor <= endDate) { + dayKeys.push(localDayKey(cursor)); + cursor.setDate(cursor.getDate() + 1); + } + const items = []; + dayKeys.forEach((dk) => { + (byDay[dk] || []).forEach((e) => { + items.push({ + name: eventDisplayName(e), + at: e.at, + dayKey: dk + }); + }); + }); + items.sort((a, b) => b.at - a.at); + streaks.push({ + startKey: startKey, + endKey: endKey, + days: length, + items + }); + }; + + for (let i = 1; i < days.length; i++) { + const prevDate = parseLocalDayKey(prev); + const curDate = parseLocalDayKey(days[i]); + const diffDays = Math.round((curDate - prevDate) / (24 * 60 * 60 * 1000)); + if (diffDays === 1) { + len += 1; + prev = days[i]; + } else { + flush(prev, len, start); + start = days[i]; + prev = days[i]; + len = 1; + } + } + flush(prev, len, start); + + streaks.sort((a, b) => b.days - a.days || b.endKey.localeCompare(a.endKey)); + return streaks.slice(0, MAX_STREAKS_SHOWN); + } + + function estimatedStartMs(event) { + return event.at.getTime() - ticksToMs(effectiveRunTimeTicks(event)); + } + + function idleGapMs(prevEvent, nextEvent) { + // Gap from when prev finished (LastPlayedDate) to when next started (LastPlayedDate − runtime) + return estimatedStartMs(nextEvent) - prevEvent.at.getTime(); + } + + /** + * Greedy binge windows: consecutive idle ≤ 4h, overall span ≤ 12h, content runtime ≥ 4h. + */ + function computeBinges(events) { + if (!events.length) return []; + const sorted = events.slice().sort((a, b) => a.at - b.at); + const binges = []; + let i = 0; + + while (i < sorted.length) { + let end = i; + let runtimeTicks = effectiveRunTimeTicks(sorted[i]); + + while (end + 1 < sorted.length) { + const next = sorted[end + 1]; + if ((next.at - sorted[i].at) > BINGE_MAX_SPAN_MS) break; + if (idleGapMs(sorted[end], next) > BINGE_MAX_IDLE_GAP_MS) break; + end += 1; + runtimeTicks += effectiveRunTimeTicks(sorted[end]); + } + + if (runtimeTicks >= BINGE_MIN_RUNTIME_TICKS) { + const slice = sorted.slice(i, end + 1); + binges.push({ + start: slice[0].at, + end: slice[slice.length - 1].at, + itemCount: slice.length, + runtimeTicks, + items: slice.map((e) => ({ + name: eventDisplayName(e), + at: e.at, + type: e.type, + runTimeTicks: effectiveRunTimeTicks(e) + })) + }); + i = end + 1; + } else { + i += 1; + } + } + + binges.sort((a, b) => b.end - a.end); + return binges.slice(0, MAX_BINGES_SHOWN); + } + + function computeFavoriteRecents(progressList, movies) { + const items = []; + + (movies || []).forEach((movie) => { + if (!movie?.UserData?.IsFavorite) return; + const key = movie.UserData.LastPlayedDate; + if (!key) return; + const at = new Date(key); + if (Number.isNaN(at.getTime())) return; + items.push({ + id: movie.Id, + name: movie.Name || 'Movie', + type: 'Movie', + at + }); + }); + + (progressList || []).forEach((progress) => { + if (!progress?.series?.UserData?.IsFavorite) return; + const series = progress.series; + let at = null; + const source = Array.isArray(progress.episodes) && progress.episodes.length + ? progress.episodes + : (progress.playEvents || []); + source.forEach((ep) => { + const key = ep?.UserData?.LastPlayedDate || ep?.LastPlayedDate; + if (!key) return; + const d = new Date(key); + if (Number.isNaN(d.getTime())) return; + if (!at || d > at) at = d; + }); + if (!at && series.UserData?.LastPlayedDate) { + const d = new Date(series.UserData.LastPlayedDate); + if (!Number.isNaN(d.getTime())) at = d; + } + if (!at) return; + items.push({ + id: series.Id, + name: series.Name || 'Series', + type: 'Series', + at + }); + }); + + items.sort((a, b) => b.at - a.at); + return items.slice(0, MAX_FAVORITES_SHOWN); + } + + function destroyCharts() { + chartInstances.forEach((chart) => { + try { + chart.destroy(); + } catch (_) { /* ignore */ } + }); + chartInstances = []; + } + + function baseChartOptions(extra = {}) { + return { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: 'rgba(20, 20, 20, 0.9)', + titleColor: '#fff', + bodyColor: '#fff' + } + }, + scales: { + x: { + ticks: { color: CHART_COLORS.text, maxRotation: 0, autoSkip: true, maxTicksLimit: 12 }, + grid: { color: CHART_COLORS.grid } + }, + y: { + beginAtZero: true, + ticks: { color: CHART_COLORS.text, precision: 0 }, + grid: { color: CHART_COLORS.grid } + } + }, + ...extra + }; + } + + function createLineChart(canvas, labels, values, label) { + if (!window.Chart || !canvas) return null; + const ctx = canvas.getContext('2d'); + const gradient = ctx.createLinearGradient(0, 0, 0, canvas.parentElement?.clientHeight || 220); + gradient.addColorStop(0, 'rgba(100, 181, 246, 0.45)'); + gradient.addColorStop(1, 'rgba(100, 181, 246, 0.02)'); + const lastIndex = Math.max(0, labels.length - 1); + + const chart = new window.Chart(ctx, { + type: 'line', + data: { + labels, + datasets: [{ + label, + data: values, + borderColor: 'rgba(100, 181, 246, 1)', + backgroundColor: gradient, + fill: 'origin', + tension: 0.35, + pointRadius: 0, + pointHoverRadius: 4, + borderWidth: 2.5, + cubicInterpolationMode: 'monotone' + }] + }, + options: baseChartOptions({ + interaction: { mode: 'index', intersect: false }, + elements: { line: { borderJoinStyle: 'round' } }, + scales: { + x: { + ticks: { + color: CHART_COLORS.text, + maxRotation: 0, + autoSkip: true, + maxTicksLimit: 12 + }, + grid: { color: CHART_COLORS.grid }, + afterBuildTicks(axis) { + if (lastIndex <= 0) return; + const byValue = new Map(); + (axis.ticks || []).forEach((t) => byValue.set(t.value, t)); + byValue.set(0, { value: 0 }); + byValue.set(lastIndex, { value: lastIndex }); + axis.ticks = Array.from(byValue.values()).sort((a, b) => a.value - b.value); + } + }, + y: { + beginAtZero: true, + ticks: { color: CHART_COLORS.text, precision: 0 }, + grid: { color: CHART_COLORS.grid } + } + } + }) + }); + chartInstances.push(chart); + return chart; + } + + function createBarChart(canvas, labels, values, label) { + if (!window.Chart || !canvas) return null; + const chart = new window.Chart(canvas.getContext('2d'), { + type: 'bar', + data: { + labels, + datasets: [{ + label, + data: values, + backgroundColor: CHART_COLORS.primary, + borderRadius: 3, + maxBarThickness: 28 + }] + }, + options: baseChartOptions() + }); + chartInstances.push(chart); + return chart; + } + + function calculateSummary(progressData, movies) { + const list = progressData || []; + const seriesStarted = list.length; + const seriesWatched = list.filter((p) => p.percentage === 100).length; + const episodesWatched = list.reduce((total, p) => total + (p.watchedCount || 0), 0); + const moviesWatched = (movies || []).length; + const topShows = list + .map((progress) => ({ + name: progress.series?.Name || 'Unknown', + episodesWatched: progress.watchedCount || 0, + totalEpisodes: progress.totalEpisodes || 0, + percentage: progress.percentage || 0, + seriesId: progress.series?.Id + })) + .sort((a, b) => { + const percentageDiff = b.percentage - a.percentage; + if (percentageDiff !== 0) return percentageDiff; + return b.totalEpisodes - a.totalEpisodes; + }) + .slice(0, 5); + + return { seriesStarted, seriesWatched, episodesWatched, moviesWatched, topShows }; + } + + function updateSummaryCards(root, stats) { + const setText = (id, value) => { + const el = root.querySelector(`#${id}`) || document.getElementById(id); + if (el) el.textContent = Number(value).toLocaleString(); + }; + setText('stat-series-started', stats.seriesStarted); + setText('stat-series-watched', stats.seriesWatched); + setText('stat-episodes-watched', stats.episodesWatched); + setText('stat-movies-watched', stats.moviesWatched); + + const topShowsList = root.querySelector('#top-shows-list') || document.getElementById('top-shows-list'); + if (!topShowsList) return; + if (!stats.topShows.length) { + topShowsList.innerHTML = '