diff --git a/README.md b/README.md index 32466b1b..f087161f 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,7 @@ pnpm dev # Development mode | `MUSIC_FOLDERS` | No | Comma-separated list of paths to music directories. Defaults to `/music` if not set. | | `PUID` | No | User ID for the container process. Defaults to `1000`. (Docker only) | | `PGID` | No | Group ID for the container process. Defaults to `1000`. (Docker only) | +| `TAGR_INSIGHTS` | No | Set to `false` to stop sending [Insights](#insights). `DO_NOT_TRACK=1` does the same. | --- @@ -278,6 +279,34 @@ and play counts inside Tagr are recorded regardless of whether scrobbling is con --- +## Insights + +Insights are Tagr's anonymous usage statistics. Once a day, Tagr sends a small anonymous report to `https://tagr-insights.xavirincon.com/collect` so we can tell +how many instances exist and which features are worth working on. The aggregated results are public at +. + +The report contains only counts and flags: + +- A random instance ID generated on first run (not derived from anything about you or your machine) +- Tagr version, OS, CPU architecture, Node version and whether it runs in a container +- Number of songs, music folders and songs per file format +- Number of users, and how many played something in the last 7 days +- Tag edits, edited songs and plays in the last 7 days +- How many smart playlists, saved filters, active share links and scrobbling accounts exist + +It never includes file names, paths, tags, user names, tokens or your IP address (the server does not store it). + +**Nothing is sent until an admin has seen the notice.** After installing or upgrading, admins get a popup +explaining what is sent, with **Keep enabled** and **Disable** buttons. Until one of them is clicked (or +the setting is changed in **Settings → Insights**, which also shows the exact JSON), no report leaves the +server. After that, a report goes out 30 minutes after start-up and then every 24 hours. The container log also +says at start-up whether statistics are on. + +To opt out, untick it in **Settings → Insights**, or set `TAGR_INSIGHTS=false` (or `DO_NOT_TRACK=1`) to +turn it off for good. The collector is open source: [suitux/tagr-insights](https://github.com/suitux/tagr-insights). + +--- + ## Docker Volumes | Container Path | Purpose | diff --git a/landing/package.json b/landing/package.json index bc292649..ef18b843 100644 --- a/landing/package.json +++ b/landing/package.json @@ -19,6 +19,7 @@ "@fontsource-variable/geist-mono": "^5.2.8", "@tailwindcss/vite": "^4.1.18", "astro": "^5.16.0", + "chart.js": "^4.5.1", "sharp": "^0.34.5", "tailwindcss": "^4.1.18" }, diff --git a/landing/pnpm-lock.yaml b/landing/pnpm-lock.yaml index a94bea51..8aae3ddf 100644 --- a/landing/pnpm-lock.yaml +++ b/landing/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: astro: specifier: ^5.16.0 version: 5.18.2(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.62.2)(typescript@5.9.3) + chart.js: + specifier: ^4.5.1 + version: 4.5.1 sharp: specifier: ^0.34.5 version: 0.34.5 @@ -576,6 +579,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@kurkle/color@0.3.4': + resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} @@ -1042,6 +1048,10 @@ packages: character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + chart.js@4.5.1: + resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} + engines: {pnpm: '>=8'} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -2439,6 +2449,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@kurkle/color@0.3.4': {} + '@oslojs/encoding@1.1.0': {} '@resvg/resvg-js-android-arm-eabi@2.6.2': @@ -2875,6 +2887,10 @@ snapshots: character-entities@2.0.2: {} + chart.js@4.5.1: + dependencies: + '@kurkle/color': 0.3.4 + chokidar@5.0.0: dependencies: readdirp: 5.0.0 diff --git a/landing/public/llms.txt b/landing/public/llms.txt index 39ede8bd..a24255e6 100644 --- a/landing/public/llms.txt +++ b/landing/public/llms.txt @@ -37,7 +37,7 @@ server itself and gives you a browser UI, on desktop and on a phone. - Mobile first responsive UI with swipe gestures, installable as a PWA. - Custom playlists, lossless detection, URL based state (bookmark or share any view). - Multi user authentication with user management built in. -- No telemetry. The only outbound call is to the MusicBrainz API, and only on request. +- Private by design: music, tags and file names never leave the server. Outbound calls: MusicBrainz lookups on request, ListenBrainz scrobbling if configured, and Insights, an anonymous daily report of counts that an admin must accept first and can turn off (public results: https://tagr.xavirincon.com/insights/). ## Supported audio formats diff --git a/landing/src/components/Insights.astro b/landing/src/components/Insights.astro new file mode 100644 index 00000000..8a49f460 --- /dev/null +++ b/landing/src/components/Insights.astro @@ -0,0 +1,100 @@ +--- +import { t, LINKS, type Locale } from '../i18n'; + +interface Props { + locale: Locale; +} + +const { locale } = Astro.props; +const d = t(locale); +const a = d.insights; + +// Overridable for local work against `wrangler dev` (PUBLIC_INSIGHTS_API=http://localhost:8787). +const api = import.meta.env.PUBLIC_INSIGHTS_API ?? LINKS.insightsApi; + +const ranges = ['30d', '90d', '1y', 'all'] as const; +const tiles = ['instances', 'songs', 'edits', 'docker'] as const; +const charts = ['instances', 'versions', 'platforms', 'librarySizes', 'features', 'activity', 'formats'] as const; +--- + +
+
+

{a.h1}

+

{a.intro}

+

+ {a.optOut} + + {a.whatIsSent} + +

+
+ +
+
+ { + ranges.map((range) => ( + + )) + } +
+

{a.loading}

+
+ +
+
+ { + tiles.map((tile) => ( +
+
{a.tiles[tile]}
+
+ – +
+
+
+ )) + } +
+ +
+ { + charts.map((chart) => ( +
+

+ {a.charts[chart].title} +

+

{a.charts[chart].caption}

+
+ +
+
+ + {a.showTable} + +
+
+
+ )) + } +
+
+
+ + diff --git a/landing/src/i18n/en.ts b/landing/src/i18n/en.ts index bc298bf5..67a6066c 100644 --- a/landing/src/i18n/en.ts +++ b/landing/src/i18n/en.ts @@ -12,6 +12,12 @@ export const en = { 'How to install and configure Tagr, the self hosted music metadata editor. Docker Compose, environment variables, music folder mounting, and troubleshooting.', ogTitle: 'Install Tagr: Docker setup and configuration', }, + insights: { + title: 'Tagr Insights: anonymous usage statistics from every instance', + description: + 'Public, aggregated and anonymous usage statistics sent by Tagr instances: active installations, versions, platforms, library sizes and feature adoption over time.', + ogTitle: 'Tagr Insights', + }, notFound: { title: 'Page not found: Tagr', description: 'That page does not exist. Head back to the Tagr landing page or the install docs.', @@ -61,7 +67,7 @@ export const en = { trust: [ { icon: 'scale', label: 'AGPL-3.0' }, { icon: 'cpu', label: 'amd64 and arm64' }, - { icon: 'shield', label: 'No telemetry' }, + { icon: 'shield', label: 'Private by design' }, { icon: 'database', label: 'SQLite, no external database' }, { icon: 'smartphone', label: 'Works on mobile' }, ], @@ -373,7 +379,7 @@ export const en = { }, { q: 'Does Tagr send any data to the cloud?', - a: 'No telemetry, no analytics, no phoning home. The one outbound call Tagr can make is to the MusicBrainz API, and only when you explicitly ask it to look a song up. Skip that feature and the container never talks to the internet.', + a: 'Your music, tags and file names never leave your server. Tagr only goes online for three things: MusicBrainz lookups when you ask for one, ListenBrainz scrobbling if you connect an account, and Insights, one anonymous daily report of counts (version, library size, which features are used). Insights waits until an admin agrees and can be turned off at any time; the results are public on the Insights page.', }, { q: 'Can I use Tagr from my phone?', @@ -429,6 +435,7 @@ export const en = { links: [ { label: 'GitHub', href: 'https://github.com/suitux/Tagr', external: true }, { label: 'Releases', href: 'https://github.com/suitux/Tagr/releases', external: true }, + { label: 'Insights', href: '/insights/' }, { label: 'Issues', href: 'https://github.com/suitux/Tagr/issues', external: true }, { label: 'License', href: 'https://github.com/suitux/Tagr/blob/main/LICENSE', external: true }, ], @@ -445,6 +452,49 @@ export const en = { rights: 'Tagr, AGPL-3.0', }, + insights: { + h1: 'Insights', + intro: + 'Insights are the anonymous usage statistics of Tagr. Every instance whose admin agreed sends one report a day: version, platform, library size and which features are in use. Never file names, paths, tags or user names. These are the aggregated results, updated live.', + optOut: 'To opt out, turn it off in Settings → Insights or set TAGR_INSIGHTS=false.', + whatIsSent: 'See exactly what is sent', + rangeLabel: 'Time range', + ranges: { '30d': '30 days', '90d': '90 days', '1y': '1 year', all: 'All time' }, + loading: 'Loading statistics…', + error: 'Statistics could not be loaded. Try again later.', + empty: 'No data yet. Statistics appear here as soon as the first reports arrive.', + updated: 'Updated {date}', + vsStart: '{delta} since {date}', + showTable: 'Show data as a table', + other: 'Other', + date: 'Date', + week: 'Week of', + tiles: { + instances: 'Active installations', + songs: 'Songs managed', + edits: 'Tag edits, last 7 days', + docker: 'Running in Docker', + }, + charts: { + instances: { title: 'Active installations', caption: 'Instances that reported on each day. The last point is live: every instance seen in the past 24 hours.' }, + versions: { title: 'Tagr versions', caption: 'Installations per minor version. Shows how fast new releases are adopted.' }, + platforms: { title: 'Operating system and architecture', caption: 'Share of installations.' }, + librarySizes: { title: 'Library size', caption: 'Share of installations by number of songs.' }, + features: { title: 'Feature adoption', caption: 'Share of installations using each feature.' }, + activity: { title: 'Activity', caption: 'Tag edits and plays in the previous 7 days, summed over every installation.' }, + formats: { title: 'File formats', caption: 'Share of all songs managed by Tagr.' }, + }, + features: { + metadataEditing: 'Tag editing', + smartPlaylists: 'Smart playlists', + savedFilters: 'Saved filters', + multiUser: 'Several users', + scrobbling: 'Scrobbling', + sharedLinks: 'Shared links', + }, + activity: { metadataEdits7d: 'Tag edits', listens7d: 'Plays' }, + }, + notFound: { h1: 'That page does not exist', body: 'The link is dead or the page moved. Two places worth trying:', @@ -532,6 +582,18 @@ export const en = { desc: 'Runtime mode. Use production for a normal deployment.', example: 'production', }, + { + name: 'TAGR_INSIGHTS', + required: 'No', + desc: 'Set to false to turn off Insights, the anonymous daily usage report, for good. It overrides the Settings toggle, and the admin is never asked about it.', + example: 'false', + }, + { + name: 'DO_NOT_TRACK', + required: 'No', + desc: 'Set to 1 to turn off Insights, same as TAGR_INSIGHTS=false. Follows the Console Do Not Track convention.', + example: '1', + }, ], }, manual: { diff --git a/landing/src/i18n/es.ts b/landing/src/i18n/es.ts index 53e10778..c47a7bef 100644 --- a/landing/src/i18n/es.ts +++ b/landing/src/i18n/es.ts @@ -14,6 +14,12 @@ export const es: Dict = { 'Cómo instalar y configurar Tagr, el editor de metadatos de música self hosted. Docker Compose, variables de entorno, montaje de carpetas de música y resolución de problemas.', ogTitle: 'Instalar Tagr: Docker y configuración', }, + insights: { + title: 'Tagr Insights: estadísticas de uso anónimas de cada instalación', + description: + 'Estadísticas de uso públicas, agregadas y anónimas que envían las instalaciones de Tagr: instalaciones activas, versiones, plataformas, tamaño de biblioteca y adopción de funciones a lo largo del tiempo.', + ogTitle: 'Tagr Insights', + }, notFound: { title: 'Página no encontrada: Tagr', description: 'Esa página no existe. Vuelve a la portada de Tagr o a la guía de instalación.', @@ -63,7 +69,7 @@ export const es: Dict = { trust: [ { icon: 'scale', label: 'AGPL-3.0' }, { icon: 'cpu', label: 'amd64 y arm64' }, - { icon: 'shield', label: 'Sin telemetría' }, + { icon: 'shield', label: 'Privado por diseño' }, { icon: 'database', label: 'SQLite, sin base de datos externa' }, { icon: 'smartphone', label: 'Funciona en el móvil' }, ], @@ -375,7 +381,7 @@ export const es: Dict = { }, { q: '¿Tagr envía datos a la nube?', - a: 'Ni telemetría, ni analítica, ni llamadas a casa. La única salida a internet que puede hacer Tagr es a la API de MusicBrainz, y solo cuando le pides explícitamente que busque una canción. Si no usas esa función, el contenedor no habla con internet.', + a: 'Tu música, tus etiquetas y los nombres de tus archivos nunca salen de tu servidor. Tagr solo se conecta a internet para tres cosas: buscar en MusicBrainz cuando se lo pides, hacer scrobbling a ListenBrainz si conectas una cuenta, e Insights, un informe anónimo diario con recuentos (versión, tamaño de la biblioteca, qué funciones se usan). Insights espera a que un admin lo acepte y se puede desactivar en cualquier momento; los resultados son públicos en la página de Insights.', }, { q: '¿Puedo usar Tagr desde el móvil?', @@ -431,6 +437,7 @@ export const es: Dict = { links: [ { label: 'GitHub', href: 'https://github.com/suitux/Tagr', external: true }, { label: 'Versiones', href: 'https://github.com/suitux/Tagr/releases', external: true }, + { label: 'Insights', href: '/es/insights/' }, { label: 'Incidencias', href: 'https://github.com/suitux/Tagr/issues', external: true }, { label: 'Licencia', href: 'https://github.com/suitux/Tagr/blob/main/LICENSE', external: true }, ], @@ -447,6 +454,49 @@ export const es: Dict = { rights: 'Tagr, AGPL-3.0', }, + insights: { + h1: 'Insights', + intro: + 'Insights son las estadísticas de uso anónimas de Tagr. Cada instalación cuyo admin lo ha aceptado envía un informe al día: versión, plataforma, tamaño de la biblioteca y qué funciones usa. Nunca nombres de archivo, rutas, etiquetas ni nombres de usuario. Estos son los resultados agregados, actualizados en directo.', + optOut: 'Para no enviarlo, desactívalo en Ajustes → Insights o define TAGR_INSIGHTS=false.', + whatIsSent: 'Ver exactamente qué se envía', + rangeLabel: 'Periodo', + ranges: { '30d': '30 días', '90d': '90 días', '1y': '1 año', all: 'Todo' }, + loading: 'Cargando estadísticas…', + error: 'No se han podido cargar las estadísticas. Inténtalo más tarde.', + empty: 'Aún no hay datos. Las estadísticas aparecen en cuanto llegan los primeros informes.', + updated: 'Actualizado el {date}', + vsStart: '{delta} desde el {date}', + showTable: 'Ver los datos en una tabla', + other: 'Otras', + date: 'Fecha', + week: 'Semana del', + tiles: { + instances: 'Instalaciones activas', + songs: 'Canciones gestionadas', + edits: 'Ediciones de etiquetas, últimos 7 días', + docker: 'Funcionando en Docker', + }, + charts: { + instances: { title: 'Instalaciones activas', caption: 'Instalaciones que enviaron su informe cada día. El último punto es en directo: todas las vistas en las últimas 24 horas.' }, + versions: { title: 'Versiones de Tagr', caption: 'Instalaciones por versión. Muestra lo rápido que se adoptan las nuevas releases.' }, + platforms: { title: 'Sistema operativo y arquitectura', caption: 'Porcentaje de instalaciones.' }, + librarySizes: { title: 'Tamaño de la biblioteca', caption: 'Porcentaje de instalaciones según su número de canciones.' }, + features: { title: 'Adopción de funciones', caption: 'Porcentaje de instalaciones que usan cada función.' }, + activity: { title: 'Actividad', caption: 'Ediciones de etiquetas y reproducciones de los 7 días anteriores, sumando todas las instalaciones.' }, + formats: { title: 'Formatos de archivo', caption: 'Porcentaje de todas las canciones que gestiona Tagr.' }, + }, + features: { + metadataEditing: 'Edición de etiquetas', + smartPlaylists: 'Listas inteligentes', + savedFilters: 'Filtros guardados', + multiUser: 'Varios usuarios', + scrobbling: 'Scrobbling', + sharedLinks: 'Enlaces compartidos', + }, + activity: { metadataEdits7d: 'Ediciones', listens7d: 'Reproducciones' }, + }, + notFound: { h1: 'Esa página no existe', body: 'El enlace está roto o la página se movió. Dos sitios que merece la pena probar:', @@ -534,6 +584,18 @@ export const es: Dict = { desc: 'Modo de ejecución. Usa production en un despliegue normal.', example: 'production', }, + { + name: 'TAGR_INSIGHTS', + required: 'No', + desc: 'Ponlo a false para desactivar Insights, el informe anónimo diario de uso, de forma permanente. Tiene prioridad sobre el ajuste de la app, y no se le pregunta al admin.', + example: 'false', + }, + { + name: 'DO_NOT_TRACK', + required: 'No', + desc: 'Ponlo a 1 para desactivar Insights, igual que TAGR_INSIGHTS=false. Sigue la convención Console Do Not Track.', + example: '1', + }, ], }, manual: { diff --git a/landing/src/i18n/index.ts b/landing/src/i18n/index.ts index 32911d73..ba99ac63 100644 --- a/landing/src/i18n/index.ts +++ b/landing/src/i18n/index.ts @@ -40,4 +40,6 @@ export const LINKS = { image: 'ghcr.io/suitux/tagr:latest', version: '1.8.6', license: 'https://www.gnu.org/licenses/agpl-3.0.html', + insightsApi: 'https://tagr-insights.xavirincon.com', + insightsDocs: 'https://github.com/suitux/Tagr#insights', } as const; diff --git a/landing/src/lib/insights-dashboard.ts b/landing/src/lib/insights-dashboard.ts new file mode 100644 index 00000000..dbcc2fbe --- /dev/null +++ b/landing/src/lib/insights-dashboard.ts @@ -0,0 +1,463 @@ +import { + CategoryScale, + Chart, + Filler, + Legend, + LinearScale, + LineController, + LineElement, + PointElement, + Tooltip, + type ChartDataset, + type Plugin, +} from 'chart.js'; +import type { en } from '../i18n/en'; + +Chart.register(LineController, LineElement, PointElement, LinearScale, CategoryScale, Filler, Tooltip, Legend); + +type Labels = (typeof en)['insights']; +type Range = '30d' | '90d' | '1y' | 'all'; +type ChartId = 'instances' | 'versions' | 'platforms' | 'librarySizes' | 'features' | 'activity' | 'formats'; + +/** Mirrors DailySummary in tagr-insights (src/summarize.ts). */ +interface DailySummary { + instances: number; + versions: Record; + platforms: Record; + containerized: { docker: number; bare: number }; + librarySizes: Record; + fileExtensions: Record; + features: Record; + totals: { songs: number; users: number; metadataEdits7d: number; listens7d: number }; +} + +interface SummaryResponse { + range: Range; + granularity: 'day' | 'week'; + /** When the live point was computed (ISO timestamp) */ + updatedAt: string | null; + totalInstances: number; + history: { day: string; summary: DailySummary }[]; +} + +interface Series { + label: string; + values: number[]; + color: string; +} + +interface ChartSpec { + kind: 'line' | 'stacked' | 'stacked-percent'; + series: Series[]; + percentAxis?: boolean; +} + +// Dark categorical palette, validated against the card surface (#171717): every adjacent pair +// clears CVD ΔE 8 and normal-vision ΔE 15, every slot clears 3:1. Assigned in this fixed order. +const CATEGORICAL = ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300']; +// "Other" is not an entity, so it gets a neutral gray instead of a seventh hue. +const OTHER = '#6e6e6e'; +// Library sizes are ordered buckets: one hue, dark → light (validated with --ordinal). +const ORDINAL = ['#184f95', '#256abf', '#3987e5', '#6da7ec', '#9ec5f4', '#cde2fb']; + +const SURFACE = '#171717'; +const GRID = 'rgba(255, 255, 255, 0.08)'; +const MUTED = '#a1a1a1'; +const FOREGROUND = '#fafafa'; + +const LIBRARY_BINS = ['0', '1-1k', '1k-10k', '10k-50k', '50k-100k', '100k+']; +const FEATURE_KEYS = ['metadataEditing', 'smartPlaylists', 'savedFilters', 'multiUser', 'scrobbling', 'sharedLinks'] as const; +const TOP_N = 5; + +/** Vertical hairline at the hovered X, so the reader aims at a date rather than a 2px line. */ +const crosshair: Plugin<'line'> = { + id: 'crosshair', + afterDatasetsDraw(chart) { + const active = chart.tooltip?.getActiveElements(); + if (!active?.length) return; + const { ctx, chartArea } = chart; + const x = active[0].element.x; + ctx.save(); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.35)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x, chartArea.top); + ctx.lineTo(x, chartArea.bottom); + ctx.stroke(); + ctx.restore(); + }, +}; + +function compareVersions(a: string, b: string): number { + const [aMajor, aMinor] = a.split('.').map(Number); + const [bMajor, bMinor] = b.split('.').map(Number); + return aMajor - bMajor || aMinor - bMinor; +} + +/** + * Keeps the TOP_N keys by their latest value and folds the rest into "Other". `order` decides + * the slot each kept key gets, so a key keeps its color for as long as it stays in the view. + */ +function topSeries( + maps: Record[], + otherLabel: string, + order: (a: string, b: string) => number = (a, b) => a.localeCompare(b), +): Series[] { + const latest = maps.at(-1) ?? {}; + const keys = new Set(maps.flatMap((map) => Object.keys(map))); + const ranked = [...keys].sort((a, b) => (latest[b] ?? 0) - (latest[a] ?? 0)); + const kept = ranked.slice(0, TOP_N).sort(order); + const folded = ranked.slice(TOP_N); + + const series: Series[] = kept.map((key, index) => ({ + label: key, + values: maps.map((map) => map[key] ?? 0), + color: CATEGORICAL[index], + })); + + if (folded.length > 0) { + series.push({ + label: otherLabel, + values: maps.map((map) => folded.reduce((sum, key) => sum + (map[key] ?? 0), 0)), + color: OTHER, + }); + } + return series; +} + +function toPercent(series: Series[]): Series[] { + const totals = series[0]?.values.map((_, index) => series.reduce((sum, item) => sum + item.values[index], 0)) ?? []; + return series.map((item) => ({ + ...item, + values: item.values.map((value, index) => (totals[index] ? (value / totals[index]) * 100 : 0)), + })); +} + +function buildSpecs(history: SummaryResponse['history'], labels: Labels): Record { + const summaries = history.map((point) => point.summary); + const instances = summaries.map((summary) => summary.instances); + + return { + instances: { + kind: 'line', + series: [{ label: labels.charts.instances.title, values: instances, color: CATEGORICAL[0] }], + }, + versions: { + kind: 'stacked', + series: topSeries( + summaries.map((summary) => summary.versions), + labels.other, + compareVersions, + ), + }, + platforms: { + kind: 'stacked-percent', + series: toPercent(topSeries(summaries.map((summary) => summary.platforms), labels.other)), + }, + librarySizes: { + kind: 'stacked-percent', + series: toPercent( + LIBRARY_BINS.map((bin, index) => ({ + label: bin, + values: summaries.map((summary) => summary.librarySizes[bin] ?? 0), + color: ORDINAL[index], + })), + ), + }, + features: { + kind: 'line', + percentAxis: true, + series: FEATURE_KEYS.map((key, index) => ({ + label: labels.features[key], + values: summaries.map((summary) => + summary.instances ? ((summary.features[key] ?? 0) / summary.instances) * 100 : 0, + ), + color: CATEGORICAL[index], + })), + }, + activity: { + kind: 'line', + series: (['metadataEdits7d', 'listens7d'] as const).map((key, index) => ({ + label: labels.activity[key], + values: summaries.map((summary) => summary.totals[key]), + color: CATEGORICAL[index], + })), + }, + formats: { + kind: 'stacked-percent', + series: toPercent(topSeries(summaries.map((summary) => summary.fileExtensions), labels.other)), + }, + }; +} + +export function initInsightsDashboard(root: HTMLElement): void { + const api = root.dataset.api!.replace(/\/$/, ''); + const locale = root.dataset.locale ?? 'en'; + const labels = JSON.parse(root.dataset.labels!) as Labels; + + const status = root.querySelector('.js-status')!; + const content = root.querySelector('.js-content')!; + const rangeButtons = [...root.querySelectorAll('.js-range')]; + + const integer = new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }); + const compact = new Intl.NumberFormat(locale, { notation: 'compact', maximumFractionDigits: 1 }); + const percent = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }); + const signed = new Intl.NumberFormat(locale, { signDisplay: 'exceptZero', maximumFractionDigits: 0 }); + + const charts = new Map>(); + let lastSpecs: Record | null = null; + let lastDates: string[] = []; + let lastGranularity: SummaryResponse['granularity'] = 'day'; + + function formatDate(day: string, withYear: boolean): string { + return new Intl.DateTimeFormat(locale, { + day: 'numeric', + month: 'short', + year: withYear ? 'numeric' : undefined, + timeZone: 'UTC', + }).format(new Date(`${day}T00:00:00Z`)); + } + + function formatValue(value: number, spec: ChartSpec): string { + return spec.kind === 'stacked-percent' || spec.percentAxis ? `${percent.format(value)} %` : integer.format(value); + } + + function setTile(id: string, value: string, delta = '') { + root.querySelector(`[data-tile="${id}"]`)!.textContent = value; + root.querySelector(`[data-tile-delta="${id}"]`)!.textContent = delta; + } + + function renderTiles(history: SummaryResponse['history']) { + const first = history[0]; + const latest = history.at(-1); + if (!latest || !first) { + for (const id of ['instances', 'songs', 'edits', 'docker']) setTile(id, '–'); + return; + } + + const since = formatDate(first.day, true); + const delta = (now: number, then: number, format: (value: number) => string) => + history.length > 1 ? labels.vsStart.replace('{delta}', format(now - then)).replace('{date}', since) : ''; + + const { summary } = latest; + setTile( + 'instances', + integer.format(summary.instances), + delta(summary.instances, first.summary.instances, (value) => signed.format(value)), + ); + setTile( + 'songs', + compact.format(summary.totals.songs), + delta(summary.totals.songs, first.summary.totals.songs, (value) => + value === 0 ? '0' : `${value > 0 ? '+' : '−'}${compact.format(Math.abs(value))}`, + ), + ); + setTile('edits', integer.format(summary.totals.metadataEdits7d)); + + const { docker, bare } = summary.containerized; + setTile('docker', docker + bare ? `${percent.format((docker / (docker + bare)) * 100)} %` : '–'); + } + + function datasetsFor(spec: ChartSpec): ChartDataset<'line', number[]>[] { + const stacked = spec.kind !== 'line'; + return spec.series.map((series, index) => ({ + label: series.label, + data: series.values, + borderColor: stacked ? SURFACE : series.color, + backgroundColor: series.color, + // Stacked bands are separated by a 2px surface gap, not by a border in another color. + borderWidth: 2, + fill: stacked ? (index === 0 ? 'origin' : '-1') : false, + pointRadius: 0, + pointHoverRadius: stacked ? 0 : 4, + pointHoverBorderColor: SURFACE, + pointHoverBorderWidth: 2, + // Curved bands overshoot each other where a series drops to zero. + tension: stacked ? 0 : 0.25, + })); + } + + function renderChart(id: ChartId, spec: ChartSpec, dates: string[], granularity: SummaryResponse['granularity']) { + const withYear = granularity === 'week'; + const chartLabels = dates.map((day) => formatDate(day, withYear)); + const existing = charts.get(id); + + if (existing) { + existing.data.labels = chartLabels; + existing.data.datasets = datasetsFor(spec); + existing.options.scales!.y!.max = spec.kind === 'stacked-percent' ? 100 : undefined; + existing.update(); + return; + } + + const canvas = root.querySelector(`[data-chart="${id}"]`)!; + const stacked = spec.kind !== 'line'; + const isPercent = spec.kind === 'stacked-percent' || spec.percentAxis; + + const chart = new Chart<'line', number[], string>(canvas, { + type: 'line', + data: { labels: chartLabels, datasets: datasetsFor(spec) }, + plugins: [crosshair], + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + interaction: { mode: 'index', intersect: false }, + scales: { + x: { + grid: { display: false }, + border: { color: GRID }, + ticks: { color: MUTED, maxRotation: 0, autoSkipPadding: 24, font: { size: 11 } }, + }, + y: { + stacked, + beginAtZero: true, + max: spec.kind === 'stacked-percent' ? 100 : undefined, + grid: { color: GRID }, + border: { display: false }, + ticks: { + color: MUTED, + font: { size: 11 }, + maxTicksLimit: 5, + callback: (value) => (isPercent ? `${value} %` : compact.format(Number(value))), + }, + }, + }, + plugins: { + legend: { + // A single series is named by the chart title; two or more always get a legend. + display: spec.series.length > 1, + position: 'bottom', + labels: { + color: MUTED, + boxWidth: 10, + boxHeight: 10, + padding: 14, + font: { size: 12 }, + usePointStyle: !stacked, + pointStyle: 'line', + }, + }, + tooltip: { + backgroundColor: '#262626', + borderColor: 'rgba(255, 255, 255, 0.12)', + borderWidth: 1, + titleColor: MUTED, + bodyColor: FOREGROUND, + padding: 10, + boxWidth: 10, + boxHeight: 2, + usePointStyle: false, + // Top of the stack first, matching the order the bands are drawn in. + itemSort: stacked ? (a, b) => b.datasetIndex - a.datasetIndex : undefined, + callbacks: { + label: (item) => ` ${formatValue(Number(item.raw), lastSpecs![id])} ${item.dataset.label}`, + labelColor: (item) => { + const color = lastSpecs![id].series[item.datasetIndex]?.color ?? MUTED; + return { borderColor: color, backgroundColor: color }; + }, + }, + }, + }, + }, + }); + charts.set(id, chart); + } + + /** Table view: every value reachable without hovering. Built lazily, with textContent only. */ + function renderTable(details: HTMLDetailsElement) { + const id = details.dataset.table as ChartId; + const spec = lastSpecs?.[id]; + const container = details.querySelector('div')!; + container.replaceChildren(); + if (!spec || !details.open) return; + + const table = document.createElement('table'); + table.className = 'w-full text-left text-xs tabular-nums'; + const head = table.createTHead().insertRow(); + for (const text of [lastGranularity === 'week' ? labels.week : labels.date, ...spec.series.map((s) => s.label)]) { + const th = document.createElement('th'); + th.className = 'sticky top-0 bg-[var(--card)] py-1.5 pr-4 font-medium text-[var(--muted-foreground)]'; + th.textContent = text; + head.appendChild(th); + } + + const body = table.createTBody(); + // Newest first: the row people look for is the latest one. + for (let index = lastDates.length - 1; index >= 0; index--) { + const row = body.insertRow(); + row.className = 'border-t border-[var(--border)]'; + const dateCell = row.insertCell(); + dateCell.className = 'py-1.5 pr-4 text-[var(--muted-foreground)]'; + dateCell.textContent = formatDate(lastDates[index], true); + for (const series of spec.series) { + const cell = row.insertCell(); + cell.className = 'py-1.5 pr-4'; + cell.textContent = formatValue(series.values[index], spec); + } + } + container.appendChild(table); + } + + const tables = [...root.querySelectorAll('.js-table')]; + for (const details of tables) { + details.addEventListener('toggle', () => renderTable(details)); + } + + async function load(range: Range) { + for (const button of rangeButtons) { + button.setAttribute('aria-pressed', String(button.dataset.range === range)); + } + // Refetch keeps the frame: the previous render stays, dimmed, until the new data lands. + content.style.opacity = '0.5'; + + try { + const response = await fetch(`${api}/summary.json?range=${range}`); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = (await response.json()) as SummaryResponse; + + if (data.history.length === 0) { + status.textContent = labels.empty; + renderTiles([]); + return; + } + + status.textContent = data.updatedAt + ? labels.updated.replace( + '{date}', + new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(data.updatedAt)), + ) + : ''; + + lastSpecs = buildSpecs(data.history, labels); + lastDates = data.history.map((point) => point.day); + lastGranularity = data.granularity; + + renderTiles(data.history); + for (const [id, spec] of Object.entries(lastSpecs) as [ChartId, ChartSpec][]) { + renderChart(id, spec, lastDates, data.granularity); + } + for (const details of tables) renderTable(details); + } catch (error) { + console.error('Could not load Tagr Insights:', error); + status.textContent = labels.error; + } finally { + content.style.opacity = ''; + } + } + + const initial = new URLSearchParams(location.search).get('range'); + const startRange: Range = initial === '30d' || initial === '1y' || initial === 'all' ? initial : '90d'; + + for (const button of rangeButtons) { + button.addEventListener('click', () => { + const range = button.dataset.range as Range; + const url = new URL(location.href); + url.searchParams.set('range', range); + history.replaceState(null, '', url); + void load(range); + }); + } + + void load(startRange); +} diff --git a/landing/src/pages/es/insights.astro b/landing/src/pages/es/insights.astro new file mode 100644 index 00000000..eda2a7dd --- /dev/null +++ b/landing/src/pages/es/insights.astro @@ -0,0 +1,18 @@ +--- +import BaseLayout from '../../layouts/BaseLayout.astro'; +import Insights from '../../components/Insights.astro'; +import { t } from '../../i18n'; + +const locale = 'es' as const; +const d = t(locale); +--- + + + + diff --git a/landing/src/pages/insights.astro b/landing/src/pages/insights.astro new file mode 100644 index 00000000..c12af036 --- /dev/null +++ b/landing/src/pages/insights.astro @@ -0,0 +1,18 @@ +--- +import BaseLayout from '../layouts/BaseLayout.astro'; +import Insights from '../components/Insights.astro'; +import { t } from '../i18n'; + +const locale = 'en' as const; +const d = t(locale); +--- + + + + diff --git a/messages/en.json b/messages/en.json index f67e9132..6fdbdb0c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -282,10 +282,11 @@ }, "welcome": { "title": "Welcome to Tagr", - "description": "Tagr needs to scan your music folders to build a local database. This makes browsing and searching your library fast and smooth. No data leaves your machine.", + "description": "Tagr needs to scan your music folders to build a local database. This makes browsing and searching your library fast and smooth. Your music and its tags never leave your machine.", "timing": "This process may take a few minutes depending on the size of your library.", "scanButton": "Scan library", - "scanning": "Scanning your library..." + "scanning": "Scanning your library...", + "insightsNotice": "Tagr can send anonymous usage statistics (Insights) once a day: counts only, never file names, paths or user names. Nothing is sent until you agree, and you can turn it off in Settings." }, "version": { "updateAvailable": "Version {version} is available", @@ -510,7 +511,8 @@ "title": "Settings", "sections": { "users": "Users", - "integrations": "Third party integrations" + "integrations": "Third party integrations", + "insights": "Insights" } }, "scrobbling": { @@ -532,5 +534,32 @@ "listens": { "title": "Recently played", "empty": "Nothing played yet" + }, + "insights": { + "title": "Insights", + "description": "Insights are Tagr's anonymous usage statistics. Once a day Tagr sends a small report (version, library size, which features are used) so we know how many people use it and what to improve. It never includes file names, paths, tags or user names.", + "enable": "Send Insights (anonymous usage statistics)", + "forcedByEnv": "Disabled by the TAGR_INSIGHTS or DO_NOT_TRACK environment variable.", + "lastSent": "Last report sent on {date}.", + "neverSent": "No report sent yet.", + "dashboard": "The aggregated results are public: see the dashboard.", + "preview": "Show exactly what is sent", + "previewHelp": "This JSON is sent to {endpoint}:", + "enabledToast": "Insights enabled", + "disabledToast": "Insights disabled", + "noticeTitle": "Help improve Tagr with Insights", + "noticeBody": "Insights are anonymous usage statistics: once a day Tagr can send a small report so we know how many people use it and which features are worth working on. Nothing has been sent yet, and nothing will be unless you keep it enabled.", + "noticeDetails": "Show the exact report", + "noticeKeep": "Keep enabled", + "noticeDisable": "Disable", + "pendingNotice": "Nothing has been sent yet: it starts once an admin confirms.", + "noticeSentTitle": "What is sent", + "noticeSent": "Tagr version, OS and architecture, number of songs and users, file formats, and how often features like tag editing, smart playlists or scrobbling are used.", + "noticeNeverTitle": "What is never sent", + "noticeNever": "File names, paths, tags, user names, tokens or your IP address.", + "noticeLater": "You can change this at any time in Settings → Insights.", + "sendNow": "Send now", + "sentToast": "Report sent", + "sendFailedToast": "Could not send the report: {error}" } } diff --git a/prisma/migrations/20261008000000_add_app_properties/migration.sql b/prisma/migrations/20261008000000_add_app_properties/migration.sql new file mode 100644 index 00000000..7da8b72b --- /dev/null +++ b/prisma/migrations/20261008000000_add_app_properties/migration.sql @@ -0,0 +1,5 @@ +-- CreateTable +CREATE TABLE "app_properties" ( + "key" TEXT NOT NULL PRIMARY KEY, + "value" TEXT NOT NULL +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e091a60e..6a3ca2da 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -246,3 +246,11 @@ model ScrobbleQueueItem { @@index([accountId, nextAttemptAt]) @@map("scrobble_queue") } + +/// Instance-wide key/value settings, not tied to a user (e.g. the anonymous insights ID). +model AppProperty { + key String @id + value String + + @@map("app_properties") +} diff --git a/src/app/(app)/app-shell.tsx b/src/app/(app)/app-shell.tsx index ff65b5b6..30424401 100644 --- a/src/app/(app)/app-shell.tsx +++ b/src/app/(app)/app-shell.tsx @@ -6,6 +6,7 @@ import { BulkSummaryModal } from '@/components/bulk-summary-modal' import { ResponsiveLayout } from '@/components/layout/responsive-layout' import { DetailPanel } from '@/components/panels/detail-panel/detail-panel' import { FolderList } from '@/components/panels/folder-list/folder-list' +import { InsightsNoticeDialog } from '@/components/insights-notice-dialog' import { ScanSummaryModal } from '@/components/scan-summary-modal' import { StarPromptDialog } from '@/components/star-prompt-dialog' import { useLibraryNavigation } from '@/hooks/use-library-navigation' @@ -40,6 +41,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { + { + updateInsights.mutate(enabled, { + onSuccess: () => toast.success(enabled ? t('enabledToast') : t('disabledToast')), + onError: error => toast.error(error.message) + }) + } + + return ( + !value && setClosedForNow(true)}> + + + + + {t('noticeTitle')} + + {t('noticeBody')} + + +
+
+

{t('noticeSentTitle')}

+

{t('noticeSent')}

+
+
+

{t('noticeNeverTitle')}

+

{t('noticeNever')}

+
+

+ {t.rich('dashboard', { + link: chunks => ( + + {chunks} + + ) + })}{' '} + {t('noticeLater')} +

+ + {/* Not an Accordion: it locks its height when opened, before the report has loaded */} +
+ + {detailsOpen && + (isPreviewLoading || !status ? ( +
+ +
+ ) : ( +
+                  {JSON.stringify(status.preview, null, 2)}
+                
+ ))} +
+
+ + + + + +
+
+ ) +} diff --git a/src/components/settings-modal/components/insights-settings.tsx b/src/components/settings-modal/components/insights-settings.tsx new file mode 100644 index 00000000..f091a2b1 --- /dev/null +++ b/src/components/settings-modal/components/insights-settings.tsx @@ -0,0 +1,111 @@ +'use client' + +import { Loader2Icon, SendIcon } from 'lucide-react' +import { toast } from 'sonner' +import { useTranslations } from 'next-intl' +import Link from 'next/link' +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Label } from '@/components/ui/label' +import { useInsights, useSendInsightsNow, useUpdateInsights } from '@/features/insights/hooks/use-insights' +import { formatDate, FULL_DATE_FORMAT } from '@/lib/date' + +const INSIGHTS_DASHBOARD_URL = 'https://tagr.xavirincon.com/insights/' + +export function InsightsSettings() { + const t = useTranslations('insights') + const { data: status, isLoading } = useInsights() + const updateInsights = useUpdateInsights() + const sendNow = useSendInsightsNow() + + const handleToggle = (enabled: boolean) => { + updateInsights.mutate(enabled, { + onSuccess: () => toast.success(enabled ? t('enabledToast') : t('disabledToast')), + onError: error => toast.error(error.message) + }) + } + + const handleSendNow = () => { + sendNow.mutate(undefined, { + onSuccess: () => toast.success(t('sentToast')), + onError: error => toast.error(t('sendFailedToast', { error: error.message })) + }) + } + + if (isLoading || !status) { + return ( +
+ +
+ ) + } + + return ( +
+
+

{t('title')}

+

{t('description')}

+
+ +
+ handleToggle(!!value)} + /> + +
+ + {status.forcedByEnv &&

{t('forcedByEnv')}

} + + {status.enabled && !status.forcedByEnv && !status.noticeAcknowledged && ( +
+

{t('pendingNotice')}

+ +
+ )} + +
+

+ {status.lastSentAt + ? t('lastSent', { date: formatDate(status.lastSentAt, FULL_DATE_FORMAT) ?? '' }) + : t('neverSent')} +

+ +
+ +

+ {t.rich('dashboard', { + link: chunks => ( + + {chunks} + + ) + })} +

+ + + + {t('preview')} + +

{t('previewHelp', { endpoint: status.endpoint })}

+
+              {JSON.stringify(status.preview, null, 2)}
+            
+
+
+
+
+ ) +} diff --git a/src/components/settings-modal/settings-modal.tsx b/src/components/settings-modal/settings-modal.tsx index c57ee757..e23ba6c7 100644 --- a/src/components/settings-modal/settings-modal.tsx +++ b/src/components/settings-modal/settings-modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { PlugIcon, SettingsIcon, UsersIcon, type LucideIcon } from 'lucide-react' +import { ChartNoAxesColumnIcon, PlugIcon, SettingsIcon, UsersIcon, type LucideIcon } from 'lucide-react' import { useState } from 'react' import { useSession } from 'next-auth/react' import { useTranslations } from 'next-intl' @@ -10,6 +10,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import type { UserRole } from '@/features/users/domain' import { hasMinimumRole } from '@/features/users/lib/hasMinimumRole' import { useBreakpoint } from '@/hooks/use-breakpoint' +import { InsightsSettings } from './components/insights-settings' import { ListenBrainzSettings } from './components/listenbrainz-settings' import { UsersSettings } from './components/users-settings/users-settings' @@ -29,7 +30,8 @@ const SETTINGS_SECTIONS = [ icon: PlugIcon, minimumRole: 'listener', Content: ListenBrainzSettings - } + }, + { id: 'insights', labelKey: 'insights', icon: ChartNoAxesColumnIcon, minimumRole: 'admin', Content: InsightsSettings } ] as const satisfies readonly SettingsSectionDefinition[] export type SettingsSection = (typeof SETTINGS_SECTIONS)[number]['id'] diff --git a/src/components/welcome-scan-state.tsx b/src/components/welcome-scan-state.tsx index a5f7bc85..8ec65e86 100644 --- a/src/components/welcome-scan-state.tsx +++ b/src/components/welcome-scan-state.tsx @@ -38,6 +38,7 @@ export function WelcomeScanState() { {isPending ? : } {isPending ? t('scanning') : t('scanButton')} +

{t('insightsNotice')}

diff --git a/src/features/insights/consts.ts b/src/features/insights/consts.ts new file mode 100644 index 00000000..feaa06ae --- /dev/null +++ b/src/features/insights/consts.ts @@ -0,0 +1,13 @@ +/** Every published version reports here, so it lives on a domain we control rather than workers.dev. */ +export const INSIGHTS_ENDPOINT = 'https://tagr-insights.xavirincon.com/collect' + +/** Same cadence as Navidrome: skip short-lived restarts, then once a day. */ +export const INSIGHTS_INITIAL_DELAY_MS = 30 * 60 * 1000 +export const INSIGHTS_INTERVAL_MS = 24 * 60 * 60 * 1000 +export const INSIGHTS_REQUEST_TIMEOUT_MS = 10 * 1000 + +export const INSIGHTS_ID_KEY = 'insightsId' +export const INSIGHTS_ENABLED_KEY = 'insightsEnabled' +export const INSIGHTS_LAST_SENT_KEY = 'insightsLastSentAt' +/** Set once an admin has answered the in-app notice. Nothing is sent before that. */ +export const INSIGHTS_NOTICE_SEEN_KEY = 'insightsNoticeSeenAt' diff --git a/src/features/insights/domain.ts b/src/features/insights/domain.ts new file mode 100644 index 00000000..d1531e1d --- /dev/null +++ b/src/features/insights/domain.ts @@ -0,0 +1,63 @@ +/** + * Anonymous usage report an instance sends once a day to the Tagr Insights server + * (github.com/suitux/tagr-insights). Only counts and flags: never paths, titles or usernames. + * + * Bump `schema` whenever a field changes meaning, so the server can tell old reports apart. + */ +export interface InsightsData { + schema: 1 + id: string + version: string + /** Seconds since the server process started */ + uptime: number + os: { + platform: string + arch: string + containerized: boolean + nodeVersion: string + } + library: { + songs: number + musicFolders: number + /** Song count per lowercase file extension, e.g. { flac: 1200, mp3: 300 } */ + fileExtensions: Record + } + users: { + total: number + /** Distinct users who played something in the last 7 days */ + listeners7d: number + } + usage7d: { + metadataEdits: number + editedSongs: number + listens: number + } + features: { + smartPlaylists: number + savedFilters: number + sharedLinks: number + /** Enabled scrobbling accounts per provider */ + scrobbleAccounts: Record + } +} + +export interface InsightsStatus { + enabled: boolean + /** Set when an env var decides, so the Settings toggle can't override it */ + forcedByEnv: boolean + endpoint: string + lastSentAt: string | null + /** False until an admin has answered the in-app notice; nothing is sent before that */ + noticeAcknowledged: boolean + /** Exactly what the next report would contain */ + preview: InsightsData +} + +export interface InsightsNoticeState { + /** The admin still has to be told that reports are sent */ + pending: boolean +} + +export type InsightsSendResult = + | { sent: true } + | { sent: false; reason: 'disabled' | 'noticePending' | 'failed'; error?: string } diff --git a/src/features/insights/hooks/use-insights.ts b/src/features/insights/hooks/use-insights.ts new file mode 100644 index 00000000..c90159fb --- /dev/null +++ b/src/features/insights/hooks/use-insights.ts @@ -0,0 +1,59 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { api } from '@/lib/axios' +import type { InsightsNoticeState, InsightsStatus } from '../domain' + +const INSIGHTS_QUERY_KEY = ['insights'] +const INSIGHTS_NOTICE_QUERY_KEY = ['insights', 'notice'] + +export function useInsights(enabled = true) { + return useQuery({ + queryKey: INSIGHTS_QUERY_KEY, + queryFn: async () => { + const { data } = await api.get<{ success: true; status: InsightsStatus }>('/insights') + return data.status + }, + enabled + }) +} + +export function useInsightsNotice(enabled: boolean) { + return useQuery({ + queryKey: INSIGHTS_NOTICE_QUERY_KEY, + queryFn: async () => { + const { data } = await api.get<{ success: true; notice: InsightsNoticeState }>('/insights/notice') + return data.notice + }, + enabled, + staleTime: Infinity + }) +} + +export function useUpdateInsights() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (enabled: boolean) => { + const { data } = await api.put<{ success: true; status: InsightsStatus }>('/insights', { enabled }) + return data.status + }, + onSuccess: status => { + queryClient.setQueryData(INSIGHTS_QUERY_KEY, status) + // Any answer, from the banner or from Settings, settles the notice + queryClient.setQueryData(INSIGHTS_NOTICE_QUERY_KEY, { pending: false }) + } + }) +} + +export function useSendInsightsNow() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async () => { + const { data } = await api.post<{ success: true; status: InsightsStatus }>('/insights/send') + return data.status + }, + onSuccess: status => { + queryClient.setQueryData(INSIGHTS_QUERY_KEY, status) + } + }) +} diff --git a/src/features/insights/insights-scheduler.ts b/src/features/insights/insights-scheduler.ts new file mode 100644 index 00000000..bdece69c --- /dev/null +++ b/src/features/insights/insights-scheduler.ts @@ -0,0 +1,23 @@ +import { INSIGHTS_INITIAL_DELAY_MS, INSIGHTS_INTERVAL_MS } from './consts' +import { logInsightsStartupState, sendInsights } from './insights.service' + +const globalForInsights = globalThis as unknown as { insightsSchedulerStarted?: boolean } + +/** + * Starts the daily insights report. Idempotent: register() can run more than once per process. + * TAGR_INSIGHTS_DEBUG=1 sends the first report right away, for testing against a local server. + */ +export function startInsightsScheduler(): void { + if (globalForInsights.insightsSchedulerStarted) return + globalForInsights.insightsSchedulerStarted = true + + void logInsightsStartupState() + + const initialDelay = process.env.TAGR_INSIGHTS_DEBUG === '1' ? 0 : INSIGHTS_INITIAL_DELAY_MS + + const initialTimer = setTimeout(() => { + void sendInsights() + setInterval(() => void sendInsights(), INSIGHTS_INTERVAL_MS).unref() + }, initialDelay) + initialTimer.unref() +} diff --git a/src/features/insights/insights.repository.ts b/src/features/insights/insights.repository.ts new file mode 100644 index 00000000..147e71a8 --- /dev/null +++ b/src/features/insights/insights.repository.ts @@ -0,0 +1,69 @@ +import { prisma } from '@/infrastructure/prisma/dbClient' + +export async function getAppProperty(key: string): Promise { + const row = await prisma.appProperty.findUnique({ where: { key } }) + return row?.value ?? null +} + +export async function setAppProperty(key: string, value: string): Promise { + await prisma.appProperty.upsert({ + where: { key }, + create: { key, value }, + update: { value } + }) +} + +export async function countSongsByExtension(): Promise> { + const rows = await prisma.song.groupBy({ by: ['extension'], _count: { _all: true } }) + + const result: Record = {} + for (const row of rows) { + const extension = row.extension.replace(/^\./, '').toLowerCase() + result[extension] = (result[extension] ?? 0) + row._count._all + } + return result +} + +export async function countRecentActivity(since: Date) { + const [metadataEdits, editedSongs, listens, listeners] = await Promise.all([ + prisma.songChangeHistory.count({ where: { changedAt: { gte: since } } }), + prisma.songChangeHistory.findMany({ + where: { changedAt: { gte: since } }, + distinct: ['songId'], + select: { songId: true } + }), + prisma.listen.count({ where: { listenedAt: { gte: since } } }), + prisma.listen.findMany({ + where: { listenedAt: { gte: since } }, + distinct: ['userId'], + select: { userId: true } + }) + ]) + + return { + metadataEdits, + editedSongs: editedSongs.length, + listens, + listeners: listeners.length + } +} + +export async function countFeatureUsage() { + const [songs, users, smartPlaylists, savedFilters, sharedLinks, scrobbleAccounts] = await Promise.all([ + prisma.song.count(), + prisma.user.count(), + prisma.smartPlaylist.count(), + prisma.savedFilter.count(), + prisma.sharedLink.count({ where: { expiresAt: { gt: new Date() } } }), + prisma.scrobbleAccount.groupBy({ by: ['provider'], where: { enabled: true }, _count: { _all: true } }) + ]) + + return { + songs, + users, + smartPlaylists, + savedFilters, + sharedLinks, + scrobbleAccounts: Object.fromEntries(scrobbleAccounts.map(row => [row.provider, row._count._all])) + } +} diff --git a/src/features/insights/insights.service.test.ts b/src/features/insights/insights.service.test.ts new file mode 100644 index 00000000..1e6e328b --- /dev/null +++ b/src/features/insights/insights.service.test.ts @@ -0,0 +1,200 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + collectInsights, + getEnvInsightsOverride, + getInsightsNoticeState, + isInsightsEnabled, + sendInsights, + setInsightsEnabled +} from './insights.service' + +const { mockGetAppProperty, mockSetAppProperty } = vi.hoisted(() => ({ + mockGetAppProperty: vi.fn(), + mockSetAppProperty: vi.fn() +})) + +vi.mock('./insights.repository', () => ({ + getAppProperty: (...args: unknown[]) => mockGetAppProperty(...args), + setAppProperty: (...args: unknown[]) => mockSetAppProperty(...args), + countSongsByExtension: async () => ({ flac: 10, mp3: 5 }), + countRecentActivity: async () => ({ metadataEdits: 7, editedSongs: 3, listens: 20, listeners: 2 }), + countFeatureUsage: async () => ({ + songs: 15, + users: 1, + smartPlaylists: 2, + savedFilters: 0, + sharedLinks: 1, + scrobbleAccounts: { listenbrainz: 1 } + }) +})) + +vi.mock('@/features/songs/song-file-helpers', () => ({ + getMusicFolders: () => ['/music/a', '/music/b'] +})) + +describe('insights.service', () => { + beforeEach(() => { + mockGetAppProperty.mockReset() + mockSetAppProperty.mockReset() + vi.unstubAllEnvs() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + describe('getEnvInsightsOverride', () => { + it('is null when no env var is set', () => { + vi.stubEnv('TAGR_INSIGHTS', '') + vi.stubEnv('DO_NOT_TRACK', '') + expect(getEnvInsightsOverride()).toBeNull() + }) + + it.each([ + ['TAGR_INSIGHTS', 'false'], + ['TAGR_INSIGHTS', 'off'], + ['DO_NOT_TRACK', '1'], + ['DO_NOT_TRACK', 'true'] + ])('disables with %s=%s', (name, value) => { + vi.stubEnv(name, value) + expect(getEnvInsightsOverride()).toBe(false) + }) + }) + + describe('isInsightsEnabled', () => { + it('is enabled by default', async () => { + mockGetAppProperty.mockResolvedValue(null) + expect(await isInsightsEnabled()).toBe(true) + }) + + it('respects the Settings toggle', async () => { + mockGetAppProperty.mockResolvedValue('false') + expect(await isInsightsEnabled()).toBe(false) + }) + + it('lets the env var win over the toggle', async () => { + vi.stubEnv('DO_NOT_TRACK', '1') + mockGetAppProperty.mockResolvedValue('true') + expect(await isInsightsEnabled()).toBe(false) + }) + }) + + describe('collectInsights', () => { + it('reuses the stored instance id', async () => { + mockGetAppProperty.mockResolvedValue('existing-id') + + const data = await collectInsights() + + expect(data.id).toBe('existing-id') + expect(mockSetAppProperty).not.toHaveBeenCalled() + }) + + it('creates and stores an id on first run', async () => { + mockGetAppProperty.mockResolvedValue(null) + + const data = await collectInsights() + + expect(data.id).toMatch(/^[0-9a-f-]{36}$/) + expect(mockSetAppProperty).toHaveBeenCalledWith('insightsId', data.id) + }) + + it('reports counts only, adding the env admin to the user total', async () => { + mockGetAppProperty.mockResolvedValue('id') + + const data = await collectInsights() + + expect(data).toMatchObject({ + schema: 1, + library: { songs: 15, musicFolders: 2, fileExtensions: { flac: 10, mp3: 5 } }, + users: { total: 2, listeners7d: 2 }, + usage7d: { metadataEdits: 7, editedSongs: 3, listens: 20 }, + features: { smartPlaylists: 2, savedFilters: 0, sharedLinks: 1, scrobbleAccounts: { listenbrainz: 1 } } + }) + }) + }) + + describe('sendInsights', () => { + it('does not call the network when disabled', async () => { + vi.stubEnv('TAGR_INSIGHTS', 'false') + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + await sendInsights() + + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('posts the report and records when it was sent', async () => { + vi.stubEnv('TAGR_INSIGHTS_ENDPOINT', 'http://localhost:8787/collect') + mockGetAppProperty.mockResolvedValue('id') + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 204 }) + vi.stubGlobal('fetch', fetchMock) + + await expect(sendInsights()).resolves.toEqual({ sent: true }) + + expect(fetchMock).toHaveBeenCalledWith('http://localhost:8787/collect', expect.objectContaining({ method: 'POST' })) + expect(mockSetAppProperty).toHaveBeenCalledWith('insightsLastSentAt', expect.any(String)) + }) + + it('holds reports back until an admin has seen the notice', async () => { + mockGetAppProperty.mockImplementation(async (key: string) => (key === 'insightsNoticeSeenAt' ? null : 'id')) + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + await sendInsights() + + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('reports a rejection by the server', async () => { + mockGetAppProperty.mockResolvedValue('id') + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 400 })) + + await expect(sendInsights()).resolves.toEqual({ sent: false, reason: 'failed', error: 'HTTP 400' }) + expect(mockSetAppProperty).not.toHaveBeenCalledWith('insightsLastSentAt', expect.anything()) + }) + + it('swallows network errors', async () => { + mockGetAppProperty.mockResolvedValue('id') + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline'))) + + await expect(sendInsights()).resolves.toEqual({ sent: false, reason: 'failed', error: 'offline' }) + expect(mockSetAppProperty).not.toHaveBeenCalledWith('insightsLastSentAt', expect.anything()) + }) + }) + + describe('notice', () => { + it('is pending until the admin answers', async () => { + mockGetAppProperty.mockResolvedValue(null) + expect(await getInsightsNoticeState()).toEqual({ pending: true }) + }) + + it('is settled once answered', async () => { + mockGetAppProperty.mockResolvedValue('2026-10-08T00:00:00.000Z') + expect(await getInsightsNoticeState()).toEqual({ pending: false }) + }) + + it('is not needed when an env var already opted out', async () => { + vi.stubEnv('TAGR_INSIGHTS', 'false') + mockGetAppProperty.mockResolvedValue(null) + expect(await getInsightsNoticeState()).toEqual({ pending: false }) + }) + + it('records the answer together with the choice', async () => { + mockGetAppProperty.mockResolvedValue(null) + + await setInsightsEnabled(false) + + expect(mockSetAppProperty).toHaveBeenCalledWith('insightsEnabled', 'false') + expect(mockSetAppProperty).toHaveBeenCalledWith('insightsNoticeSeenAt', expect.any(String)) + }) + + it('keeps the first answer date when toggled again', async () => { + mockGetAppProperty.mockResolvedValue('2026-10-08T00:00:00.000Z') + + await setInsightsEnabled(true) + + expect(mockSetAppProperty).not.toHaveBeenCalledWith('insightsNoticeSeenAt', expect.anything()) + }) + }) +}) diff --git a/src/features/insights/insights.service.ts b/src/features/insights/insights.service.ts new file mode 100644 index 00000000..6f51f57c --- /dev/null +++ b/src/features/insights/insights.service.ts @@ -0,0 +1,196 @@ +import { randomUUID } from 'crypto' +import { existsSync } from 'fs' +import { getMusicFolders } from '@/features/songs/song-file-helpers' +import { SEVEN_DAYS_MS } from '@/lib/date' +import { + INSIGHTS_ENABLED_KEY, + INSIGHTS_ENDPOINT, + INSIGHTS_ID_KEY, + INSIGHTS_LAST_SENT_KEY, + INSIGHTS_NOTICE_SEEN_KEY, + INSIGHTS_REQUEST_TIMEOUT_MS +} from './consts' +import type { InsightsData, InsightsNoticeState, InsightsSendResult, InsightsStatus } from './domain' +import { + countFeatureUsage, + countRecentActivity, + countSongsByExtension, + getAppProperty, + setAppProperty +} from './insights.repository' + +export function getInsightsEndpoint(): string { + return process.env.TAGR_INSIGHTS_ENDPOINT || INSIGHTS_ENDPOINT +} + +/** An env var wins over the Settings toggle: `false` from either TAGR_INSIGHTS or DO_NOT_TRACK. */ +export function getEnvInsightsOverride(): boolean | null { + const flag = process.env.TAGR_INSIGHTS?.trim().toLowerCase() + if (flag === 'false' || flag === '0' || flag === 'off') return false + + const doNotTrack = process.env.DO_NOT_TRACK?.trim().toLowerCase() + if (doNotTrack === '1' || doNotTrack === 'true') return false + + return null +} + +export async function isInsightsEnabled(): Promise { + const override = getEnvInsightsOverride() + if (override !== null) return override + + return (await getAppProperty(INSIGHTS_ENABLED_KEY)) !== 'false' +} + +export async function isInsightsNoticeAcknowledged(): Promise { + return (await getAppProperty(INSIGHTS_NOTICE_SEEN_KEY)) !== null +} + +/** Answering the notice or touching the Settings toggle both count as the admin having been told. */ +export async function setInsightsEnabled(enabled: boolean): Promise { + await setAppProperty(INSIGHTS_ENABLED_KEY, String(enabled)) + if (!(await isInsightsNoticeAcknowledged())) { + await setAppProperty(INSIGHTS_NOTICE_SEEN_KEY, new Date().toISOString()) + } +} + +/** An env opt-out needs no notice: the admin already decided. */ +export async function getInsightsNoticeState(): Promise { + if (getEnvInsightsOverride() === false) return { pending: false } + return { pending: !(await isInsightsNoticeAcknowledged()) } +} + +async function getOrCreateInsightsId(): Promise { + const existing = await getAppProperty(INSIGHTS_ID_KEY) + if (existing) return existing + + const id = randomUUID() + await setAppProperty(INSIGHTS_ID_KEY, id) + return id +} + +function isContainerized(): boolean { + return existsSync('/.dockerenv') || existsSync('/run/.containerenv') +} + +export async function collectInsights(): Promise { + const since = new Date(Date.now() - SEVEN_DAYS_MS) + + const [id, fileExtensions, activity, usage] = await Promise.all([ + getOrCreateInsightsId(), + countSongsByExtension(), + countRecentActivity(since), + countFeatureUsage() + ]) + + return { + schema: 1, + id, + version: process.env.APP_VERSION ?? '0.0.0', + uptime: Math.round(process.uptime()), + os: { + platform: process.platform, + arch: process.arch, + containerized: isContainerized(), + nodeVersion: process.versions.node + }, + library: { + songs: usage.songs, + musicFolders: getMusicFolders().length, + fileExtensions + }, + users: { + // The AUTH_USER admin lives in env, not in the users table + total: usage.users + 1, + listeners7d: activity.listeners + }, + usage7d: { + metadataEdits: activity.metadataEdits, + editedSongs: activity.editedSongs, + listens: activity.listens + }, + features: { + smartPlaylists: usage.smartPlaylists, + savedFilters: usage.savedFilters, + sharedLinks: usage.sharedLinks, + scrobbleAccounts: usage.scrobbleAccounts + } + } +} + +/** + * One attempt, no retries: a lost report only costs one day of one instance. + * Nothing leaves the server until an admin has seen the in-app notice, so an upgrade never + * starts reporting behind anyone's back. + */ +export async function sendInsights(): Promise { + if (!(await isInsightsEnabled())) return { sent: false, reason: 'disabled' } + if (!(await isInsightsNoticeAcknowledged())) return { sent: false, reason: 'noticePending' } + + const endpoint = getInsightsEndpoint() + + try { + const data = await collectInsights() + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + signal: AbortSignal.timeout(INSIGHTS_REQUEST_TIMEOUT_MS) + }) + + if (!response.ok) { + console.warn(`Insights report rejected by ${endpoint}: ${response.status}`) + return { sent: false, reason: 'failed', error: `HTTP ${response.status}` } + } + + await setAppProperty(INSIGHTS_LAST_SENT_KEY, new Date().toISOString()) + return { sent: true } + } catch (error) { + // fetch only says "fetch failed"; the reason (DNS, TLS, refused…) is in `cause` + const cause = error instanceof Error ? (error.cause as { code?: string; message?: string } | undefined) : undefined + const message = cause?.code ?? cause?.message ?? (error instanceof Error ? error.message : String(error)) + console.warn(`Could not send insights report to ${endpoint}:`, message) + return { sent: false, reason: 'failed', error: message } + } +} + +export async function getInsightsStatus(): Promise { + const [enabled, lastSentAt, noticeAcknowledged, preview] = await Promise.all([ + isInsightsEnabled(), + getAppProperty(INSIGHTS_LAST_SENT_KEY), + isInsightsNoticeAcknowledged(), + collectInsights() + ]) + + return { + enabled, + forcedByEnv: getEnvInsightsOverride() !== null, + endpoint: getInsightsEndpoint(), + lastSentAt, + noticeAcknowledged, + preview + } +} + +/** One line in the container logs, for admins who read those rather than the UI. */ +export async function logInsightsStartupState(): Promise { + try { + if (getEnvInsightsOverride() === false) { + console.info('Insights (anonymous usage statistics) are disabled by TAGR_INSIGHTS / DO_NOT_TRACK.') + return + } + if (!(await isInsightsEnabled())) { + console.info('Insights (anonymous usage statistics) are disabled in Settings.') + return + } + const base = + 'Insights: Tagr sends anonymous usage statistics once a day (counts only, see the README). ' + + 'Turn them off in Settings → Insights or with TAGR_INSIGHTS=false.' + console.info( + (await isInsightsNoticeAcknowledged()) + ? base + : `${base} Nothing is sent until an admin has seen the notice in the web UI.` + ) + } catch (error) { + console.warn('Could not read the Insights setting:', error instanceof Error ? error.message : error) + } +} diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 00000000..b54903e4 --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,9 @@ +export async function register() { + if (process.env.NEXT_RUNTIME !== 'nodejs') return + // `next build` loads instrumentation too, and there is no database to read there + if (process.env.NEXT_PHASE === 'phase-production-build') return + if (process.env.NODE_ENV !== 'production' && process.env.TAGR_INSIGHTS_DEBUG !== '1') return + + const { startInsightsScheduler } = await import('@/features/insights/insights-scheduler') + startInsightsScheduler() +} diff --git a/src/lib/date.ts b/src/lib/date.ts index b16d2245..0449d886 100644 --- a/src/lib/date.ts +++ b/src/lib/date.ts @@ -3,6 +3,7 @@ import { format, formatDuration, intervalToDuration, isSameDay, isValid, parse, export const ISO_DATE_FORMAT = 'yyyy-MM-dd' export const DISPLAY_DATE_FORMAT = 'MMM d' export const FULL_DATE_FORMAT = 'dd MMMM yyyy, HH:mm' +export const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000 export function formatDate(date: Date | string | null, fmt: string = ISO_DATE_FORMAT): string | null { if (!date) return null