add umami analytics integration

This commit is contained in:
jeffvli
2025-11-26 01:16:17 -08:00
parent c77d38fca0
commit 778d878349
13 changed files with 594 additions and 19 deletions
+3
View File
@@ -456,6 +456,7 @@
}, },
"setting": { "setting": {
"advanced": "advanced", "advanced": "advanced",
"analytics": "analytics",
"generalTab": "general", "generalTab": "general",
"hotkeysTab": "hotkeys", "hotkeysTab": "hotkeys",
"playbackTab": "playback", "playbackTab": "playback",
@@ -565,6 +566,8 @@
"albumBackground": "album background image", "albumBackground": "album background image",
"albumBackgroundBlur_description": "adjusts the amount of blur applied to the album background image", "albumBackgroundBlur_description": "adjusts the amount of blur applied to the album background image",
"albumBackgroundBlur": "album background image blur size", "albumBackgroundBlur": "album background image blur size",
"analyticsDisable": "Opt-out of usage based analytics",
"analyticsDisable_description": "Anonymized usage data is sent to the developer to help improve the application",
"applicationHotkeys_description": "configure application hotkeys. toggle the checkbox to set as a global hotkey (desktop only)", "applicationHotkeys_description": "configure application hotkeys. toggle the checkbox to set as a global hotkey (desktop only)",
"applicationHotkeys": "application hotkeys", "applicationHotkeys": "application hotkeys",
"artistBackground": "artist background image", "artistBackground": "artist background image",
@@ -0,0 +1,3 @@
export const isAnalyticsDisabled = () => {
return localStorage.getItem('umami.disabled') === '1' || process.env.NODE_ENV === 'development';
};
@@ -0,0 +1,272 @@
import { mutationOptions, useMutation } from '@tanstack/react-query';
import dayjs from 'dayjs';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
import isElectron from 'is-electron';
import { useEffect } from 'react';
dayjs.extend(utc);
dayjs.extend(timezone);
import packageJson from '../../../../../package.json';
import { isAnalyticsDisabled } from '/@/renderer/features/analytics/hooks/use-analytics-disabled';
import {
BarAlign,
PlayerbarSliderType,
SideQueueType,
useAuthStore,
usePlayerStore,
useSettingsStore,
} from '/@/renderer/store';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
import { ServerType } from '/@/shared/types/domain-types';
import {
FontType,
Platform,
PlayerQueueType,
PlayerStyle,
PlayerType,
} from '/@/shared/types/types';
const utils = isElectron() ? window.api.utils : null;
const getVersion = (): AppTrackerProperties['_version'] => {
return packageJson.version;
};
const getPlatform = (): AppTrackerProperties['_platform'] => {
if (!isElectron()) {
return Platform.WEB;
}
if (utils?.isWindows()) {
return Platform.WINDOWS;
}
if (utils?.isMacOS()) {
return Platform.MACOS;
}
if (utils?.isLinux()) {
return Platform.LINUX;
}
return 'unknown';
};
type AppTrackerProperties = {
_platform: 'unknown' | Platform;
_version: string;
player: {
mediaSession: boolean;
playerQueueType: PlayerQueueType;
playerStyle: PlayerStyle;
playerType: PlayerType;
transcoding: boolean;
webAudio: boolean;
};
server: {
[ServerType.JELLYFIN]: number;
[ServerType.NAVIDROME]: number;
[ServerType.SUBSONIC]: number;
};
settings: {
albumBackground: boolean;
albumBackgroundBlur: number;
artistBackground: boolean;
artistBackgroundBlur: number;
customCss: boolean;
disableAutoUpdate: boolean;
discord: boolean;
exitToTray: boolean;
fontType: FontType;
globalHotkeys: boolean;
homeFeature: boolean;
language: string;
lastFM: boolean;
lyricsEnableAutoTranslation: boolean;
lyricsEnableNeteaseTranslation: boolean;
lyricsFetch: boolean;
minimizeToTray: boolean;
musicBrainz: boolean;
nativeAspectRatio: boolean;
playerbarSliderType: PlayerbarSliderType;
playerbarWaveformAlign: BarAlign;
playerbarWaveformBarWidth: number;
playerbarWaveformGap: number;
playerbarWaveformRadius: number;
preventSleepOnPlayback: boolean;
releaseChannel: string;
resume: boolean;
scrobbleEnabled: boolean;
sideQueueType: SideQueueType;
skipBackwardSeconds: number;
skipButtons: boolean;
skipForwardSeconds: number;
startMinimized: boolean;
theme: string;
tray: boolean;
windowBarStyle: Platform;
zoomFactor: number;
};
};
const getPlayerProperties = (): AppTrackerProperties['player'] => {
const player = usePlayerStore.getState();
const playbackSettings = useSettingsStore.getState().playback;
return {
mediaSession: playbackSettings.mediaSession,
playerQueueType: player.player.queueType,
playerStyle: player.player.transitionType,
playerType: playbackSettings.type,
transcoding: playbackSettings.transcode.enabled,
webAudio: playbackSettings.webAudio,
};
};
const getSettingsProperties = (): AppTrackerProperties['settings'] => {
const settings = useSettingsStore.getState();
return {
albumBackground: settings.general.albumBackground,
albumBackgroundBlur: settings.general.albumBackgroundBlur,
artistBackground: settings.general.artistBackground,
artistBackgroundBlur: settings.general.artistBackgroundBlur,
customCss: settings.css.enabled,
disableAutoUpdate: settings.window.disableAutoUpdate,
discord: settings.discord.enabled,
exitToTray: settings.window.exitToTray,
fontType: settings.font.type,
globalHotkeys: settings.hotkeys.globalMediaHotkeys,
homeFeature: settings.general.homeFeature,
language: settings.general.language,
lastFM: settings.general.lastFM,
lyricsEnableAutoTranslation: settings.lyrics.enableAutoTranslation,
lyricsEnableNeteaseTranslation: settings.lyrics.enableNeteaseTranslation,
lyricsFetch: settings.lyrics.fetch,
minimizeToTray: settings.window.minimizeToTray,
musicBrainz: settings.general.musicBrainz,
nativeAspectRatio: settings.general.nativeAspectRatio,
playerbarSliderType: settings.general.playerbarSlider.type as PlayerbarSliderType,
playerbarWaveformAlign: settings.general.playerbarSlider.barAlign as BarAlign,
playerbarWaveformBarWidth: settings.general.playerbarSlider.barWidth,
playerbarWaveformGap: settings.general.playerbarSlider.barGap,
playerbarWaveformRadius: settings.general.playerbarSlider.barRadius,
preventSleepOnPlayback: settings.window.preventSleepOnPlayback,
releaseChannel: settings.window.releaseChannel,
resume: settings.general.resume,
scrobbleEnabled: settings.playback.scrobble.enabled,
sideQueueType: settings.general.sideQueueType,
skipBackwardSeconds: settings.general.skipButtons.skipBackwardSeconds,
skipButtons: settings.general.skipButtons.enabled,
skipForwardSeconds: settings.general.skipButtons.skipForwardSeconds,
startMinimized: settings.window.startMinimized,
theme: settings.general.theme,
tray: settings.window.tray,
windowBarStyle: settings.window.windowBarStyle,
zoomFactor: settings.general.zoomFactor,
};
};
const getServerProperties = (): AppTrackerProperties['server'] => {
const auth = useAuthStore.getState();
const serverList = auth.serverList;
return Object.entries(serverList).reduce(
(acc, [, server]) => {
if (server.type === ServerType.JELLYFIN) {
acc[ServerType.JELLYFIN] += 1;
} else if (server.type === ServerType.NAVIDROME) {
acc[ServerType.NAVIDROME] += 1;
} else if (server.type === ServerType.SUBSONIC) {
acc[ServerType.SUBSONIC] += 1;
}
return acc;
},
{
[ServerType.JELLYFIN]: 0,
[ServerType.NAVIDROME]: 0,
[ServerType.SUBSONIC]: 0,
},
);
};
export const useAppTracker = () => {
const { mutate: trackAppMutation } = useMutation(appTrackerMutation);
useEffect(() => {
if (!window.umami || isAnalyticsDisabled()) {
return;
}
const getProperties = () => {
const platform = getPlatform();
const version = getVersion();
const playerProperties = getPlayerProperties();
const serverProperties = getServerProperties();
const settingsProperties = getSettingsProperties();
const properties: AppTrackerProperties = {
_platform: platform,
_version: version,
player: playerProperties,
server: serverProperties,
settings: settingsProperties,
};
return properties;
};
const checkAndTrack = () => {
const lastSentDate = localStorage.getItem('analytics_app_tracker_timestamp');
const todayPST = dayjs().tz('America/Los_Angeles').format('YYYY-MM-DD');
// Only send if it's a new day in PST
if (lastSentDate !== todayPST) {
const properties = getProperties();
trackAppMutation(properties, {
onSettled: () => {
logFn.debug(logMsg[LogCategory.ANALYTICS].appTracked, {
category: LogCategory.ANALYTICS,
meta: { properties },
});
const pstDate = dayjs().tz('America/Los_Angeles').format('YYYY-MM-DD');
localStorage.setItem('analytics_app_tracker_timestamp', pstDate);
},
});
}
};
// Check immediately on mount
checkAndTrack();
// Then check every hour
const interval = setInterval(checkAndTrack, 1000 * 60 * 60);
return () => clearInterval(interval);
}, [trackAppMutation]);
};
const appTrackerMutation = mutationOptions({
mutationFn: (properties: AppTrackerProperties) => {
try {
window.umami?.track((props) => ({
...props,
data: properties,
name: 'app',
}));
return Promise.resolve();
} catch (error) {
return Promise.reject(error);
}
},
mutationKey: ['analytics', 'settings-tracker'],
onSuccess: () => {},
retry: false,
throwOnError: false,
});
@@ -0,0 +1,54 @@
import { mutationOptions, useMutation } from '@tanstack/react-query';
import { useEffect, useMemo } from 'react';
import { useLocation } from 'react-router';
import { isAnalyticsDisabled } from '/@/renderer/features/analytics/hooks/use-analytics-disabled';
import { getRoutePattern } from '/@/renderer/features/analytics/utils/get-route-pattern';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
const trackPageView = (routePattern: string) => {
window.umami?.track((props) => ({
language: props.language,
url: routePattern,
website: props.website,
}));
};
export const usePageTracker = () => {
const location = useLocation();
const routePattern = useMemo(() => getRoutePattern(location.pathname), [location.pathname]);
const { mutate: trackPageViewMutation } = useMutation(pageTrackerMutation);
useEffect(() => {
if (!window.umami || isAnalyticsDisabled()) {
return;
}
trackPageViewMutation(routePattern, {
onSettled: () => {
logFn.debug(logMsg[LogCategory.ANALYTICS].pageViewTracked, {
category: LogCategory.ANALYTICS,
meta: { route: routePattern },
});
},
});
}, [routePattern, trackPageViewMutation]);
};
const pageTrackerMutation = mutationOptions({
gcTime: 0,
mutationFn: (routePattern: string) => {
try {
trackPageView(routePattern);
return Promise.resolve();
} catch (error) {
return Promise.reject(error);
}
},
mutationKey: ['analytics', 'page-tracker'],
retry: false,
throwOnError: false,
});
@@ -0,0 +1,27 @@
import { matchPath } from 'react-router';
import { AppRoute } from '/@/renderer/router/routes';
export const getRoutePattern = (pathname: string): string => {
const routePatterns = Object.values(AppRoute);
const sortedRoutes = routePatterns.sort((a, b) => b.split('/').length - a.split('/').length);
for (const pattern of sortedRoutes) {
const match = matchPath(
{
caseSensitive: false,
end: true,
path: pattern,
},
pathname,
);
if (match) {
return pattern;
}
}
// Fallback to the default route if no pattern matches
return '/';
};
@@ -1,5 +1,6 @@
import { Fragment } from 'react/jsx-runtime'; import { Fragment } from 'react/jsx-runtime';
import { AnalyticsSettings } from '/@/renderer/features/settings/components/advanced/analytics-settings';
import { ExportImportSettings } from '/@/renderer/features/settings/components/advanced/export-import-settings'; import { ExportImportSettings } from '/@/renderer/features/settings/components/advanced/export-import-settings';
import { CacheSettings } from '/@/renderer/features/settings/components/window/cache-settngs'; import { CacheSettings } from '/@/renderer/features/settings/components/window/cache-settngs';
import { UpdateSettings } from '/@/renderer/features/settings/components/window/update-settings'; import { UpdateSettings } from '/@/renderer/features/settings/components/window/update-settings';
@@ -8,6 +9,7 @@ import { Stack } from '/@/shared/components/stack/stack';
const sections = [ const sections = [
{ component: UpdateSettings, key: 'update' }, { component: UpdateSettings, key: 'update' },
{ component: AnalyticsSettings, key: 'analytics' },
{ component: ExportImportSettings, key: 'export-import' }, { component: ExportImportSettings, key: 'export-import' },
{ component: CacheSettings, key: 'cache' }, { component: CacheSettings, key: 'cache' },
]; ];
@@ -0,0 +1,39 @@
import { useTranslation } from 'react-i18next';
import {
SettingOption,
SettingsSection,
} from '/@/renderer/features/settings/components/settings-section';
import { Switch } from '/@/shared/components/switch/switch';
export const AnalyticsSettings = () => {
const { t } = useTranslation();
const handleToggleAnalytics = (disable: boolean) => {
if (disable) {
localStorage.setItem('umami.disabled', '1');
} else {
localStorage.removeItem('umami.disabled');
}
};
const analyticsOptions: SettingOption[] = [
{
control: (
<Switch
defaultChecked={localStorage.getItem('umami.disabled') === '1'}
onChange={(e) => handleToggleAnalytics(e.currentTarget.checked)}
/>
),
description: t('setting.analyticsDisable_description', { postProcess: 'sentenceCase' }),
title: t('setting.analyticsDisable', { postProcess: 'sentenceCase' }),
},
];
return (
<SettingsSection
options={analyticsOptions}
title={t('page.setting.analytics', { postProcess: 'sentenceCase' })}
/>
);
};
+23
View File
@@ -0,0 +1,23 @@
declare global {
interface Window {
umami?: {
identify(unique_id: string): void;
identify(unique_id: string, data: object): void;
identify(data: object): void;
track(event_name: string, data: object): void;
track(
callback: (props: {
hostname: string;
language: string;
referrer: string;
screen: string;
title: string;
url: string;
website: string;
}) => object,
): void;
};
}
}
export {};
+21 -16
View File
@@ -1,22 +1,27 @@
<!doctype html> <!doctype html>
<html> <html>
<head>
<head> <meta charset="utf-8" />
<meta charset="utf-8" /> <meta http-equiv="Content-Security-Policy" />
<meta http-equiv="Content-Security-Policy" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1"> <script
<title>Feishin</title> defer
<% if (web) { %> src="https://umami.jeffvli.org/script.js"
<link rel="icon" href="./assets/favicon.ico"> data-website-id="5120fc56-cffa-4d42-8b6c-9afb6f459251"
data-exclude-search="true"
data-exclude-hash="true"
data-auto-track="false"
></script>
<title>Feishin</title>
<% if (web) { %>
<link rel="icon" href="./assets/favicon.ico" />
<script src="settings.js"></script> <script src="settings.js"></script>
<% } %> <% } %>
</head> </head>
<body style="background-color: #000;">
<div id="root">
<script type="module" src="main.tsx"></script>
</div>
</body>
<body style="background-color: #000">
<div id="root">
<script type="module" src="main.tsx"></script>
</div>
</body>
</html> </html>
@@ -144,9 +144,15 @@ export const MainContent = ({ shell }: { shell?: boolean }) => {
/> />
</> </>
)} )}
<Suspense fallback={<Spinner container />}> <MainContentBody />
<Outlet />
</Suspense>
</motion.div> </motion.div>
); );
}; };
function MainContentBody() {
return (
<Suspense fallback={<Spinner container />}>
<Outlet />
</Suspense>
);
}
@@ -1,6 +1,7 @@
import isElectron from 'is-electron'; import isElectron from 'is-electron';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useAppTracker } from '/@/renderer/features/analytics/hooks/use-app-tracker';
import { CommandPalette } from '/@/renderer/features/search/components/command-palette'; import { CommandPalette } from '/@/renderer/features/search/components/command-palette';
import { useIsMobile } from '/@/renderer/hooks/use-is-mobile'; import { useIsMobile } from '/@/renderer/hooks/use-is-mobile';
import { DefaultLayout } from '/@/renderer/layouts/default-layout'; import { DefaultLayout } from '/@/renderer/layouts/default-layout';
@@ -29,6 +30,8 @@ const ResponsiveLayoutBase = ({ shell }: ResponsiveLayoutProps) => {
}; };
export const ResponsiveLayout = ({ shell }: ResponsiveLayoutProps) => { export const ResponsiveLayout = ({ shell }: ResponsiveLayoutProps) => {
useAppTracker();
return ( return (
<> <>
<ResponsiveLayoutBase shell={shell} /> <ResponsiveLayoutBase shell={shell} />
+12
View File
@@ -0,0 +1,12 @@
import { LogCategory } from '/@/renderer/utils/logger';
export const logMsg = {
[LogCategory.ANALYTICS]: {
appTracked: 'App tracked',
pageViewTracked: 'Page view tracked',
},
[LogCategory.API]: {},
[LogCategory.OTHER]: {},
[LogCategory.PLAYER]: {},
[LogCategory.SYSTEM]: {},
};
+126
View File
@@ -0,0 +1,126 @@
import dayjs from 'dayjs';
export enum LogCategory {
ANALYTICS = 'analytics',
API = 'api',
OTHER = 'other',
PLAYER = 'player',
SYSTEM = 'system',
}
export type LogLevel = 'debug' | 'error' | 'info' | 'warn';
interface LogFn {
(
message?: string,
options?: {
category?: string;
meta?: any;
},
): void;
}
interface Logger {
debug: LogFn;
error: LogFn;
info: LogFn;
warn: LogFn;
}
const DEFAULT_LOG_LEVEL = process.env.NODE_ENV === 'production' ? 'info' : 'debug';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const NO_OP: LogFn = (_message?: string, ..._optionalParams: any[]) => {};
const colors = {
debug: '\x1B[38;2;54;96;146m', // #366092
error: '\x1B[38;2;240;0;0m', // #f00000
info: '\x1B[38;2;0;125;60m', // #007d3c
warn: '\x1B[38;2;225;125;50m', // #e17d32
};
// Debounce configuration
const DEBOUNCE_INTERVAL = 200; // milliseconds
const DEBOUNCE_MAP = new Map<string, { count: number; lastLog: number }>();
// Periodically flush the debounce map
setInterval(() => {
const now = Date.now();
for (const [key, value] of DEBOUNCE_MAP.entries()) {
if (now - value.lastLog >= DEBOUNCE_INTERVAL) {
const [level, message, category, meta] = JSON.parse(key);
const timestampStr = `${dayjs().format('HH:mm:ss')}`;
const levelStr = `${colors[level as keyof typeof colors]}[${String(level).toUpperCase().padEnd(5, ' ')}]\x1B[0m`;
const countStr = value.count > 1 ? ` (x${value.count})` : '';
const categoryStr = category
? String(`[${category.padEnd(9, ' ')}]`).toUpperCase()
: '';
const messageStr = message ? String(message) : '';
const logStr = `[${timestampStr}] ${levelStr} ${categoryStr} ${messageStr}${countStr}`;
if (meta) {
console.log(logStr, meta);
} else {
console.log(logStr);
}
DEBOUNCE_MAP.delete(key);
}
}
}, DEBOUNCE_INTERVAL);
class ConsoleLogger implements Logger {
readonly debug: LogFn;
readonly error: LogFn;
readonly info: LogFn;
readonly warn: LogFn;
constructor() {
const level = localStorage.getItem('log_level') || DEFAULT_LOG_LEVEL;
// Create timestamp wrapper function with colors and debouncing
const withTimestamp = (logLevel: string): LogFn => {
return (message?: any, options?: { category?: string; meta?: any }) => {
const { category, meta } = options || {};
const key = JSON.stringify([logLevel, message, category, meta]);
const now = Date.now();
const existing = DEBOUNCE_MAP.get(key);
if (existing) {
existing.count++;
existing.lastLog = now;
} else {
DEBOUNCE_MAP.set(key, { count: 1, lastLog: now });
}
};
};
this.error = withTimestamp('error');
if (level === 'error') {
this.warn = NO_OP;
this.info = NO_OP;
this.debug = NO_OP;
return;
}
this.warn = withTimestamp('warn');
if (level === 'warn') {
this.info = NO_OP;
this.debug = NO_OP;
return;
}
this.info = withTimestamp('info');
if (level === 'info') {
this.debug = NO_OP;
return;
}
this.debug = withTimestamp('debug');
}
}
export const logFn = new ConsoleLogger();