Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

---

Expand Down Expand Up @@ -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
<https://tagr.xavirincon.com/insights/>.

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 |
Expand Down
1 change: 1 addition & 0 deletions landing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
16 changes: 16 additions & 0 deletions landing/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion landing/public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
100 changes: 100 additions & 0 deletions landing/src/components/Insights.astro
Original file line number Diff line number Diff line change
@@ -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;
---

<div
id="insights"
class="container-page py-14 md:py-20"
data-api={api}
data-locale={locale}
data-labels={JSON.stringify(a)}
>
<header class="max-w-3xl">
<h1 class="text-[clamp(2rem,4.5vw,3rem)] leading-[1.1] font-semibold tracking-[-0.03em]">{a.h1}</h1>
<p class="mt-5 text-[1.0625rem] leading-relaxed text-[var(--muted-foreground)]">{a.intro}</p>
<p class="mt-3 text-sm leading-relaxed text-[var(--muted-foreground)]">
{a.optOut}
<a href={LINKS.insightsDocs} target="_blank" rel="noopener noreferrer" class="underline hover:text-[var(--foreground)]">
{a.whatIsSent}
</a>
</p>
</header>

<div class="mt-10 flex flex-wrap items-center gap-x-6 gap-y-3">
<div role="group" aria-label={a.rangeLabel} class="inline-flex rounded-[var(--radius-lg)] border border-[var(--border)] p-1">
{
ranges.map((range) => (
<button
type="button"
data-range={range}
aria-pressed={range === '90d' ? 'true' : 'false'}
class="js-range rounded-[var(--radius-md)] px-3 py-1.5 text-sm text-[var(--muted-foreground)] transition-colors hover:text-[var(--foreground)] aria-pressed:bg-[var(--accent)] aria-pressed:font-medium aria-pressed:text-[var(--foreground)]"
>
{a.ranges[range]}
</button>
))
}
</div>
<p class="js-status text-sm text-[var(--muted-foreground)]" aria-live="polite">{a.loading}</p>
</div>

<div class="js-content mt-8 transition-opacity duration-200">
<dl class="grid grid-cols-2 gap-4 lg:grid-cols-4">
{
tiles.map((tile) => (
<div class="surface p-5">
<dt class="text-sm text-[var(--muted-foreground)]">{a.tiles[tile]}</dt>
<dd class="mt-2 text-3xl font-semibold tracking-[-0.02em] tabular-nums" data-tile={tile}>
–
</dd>
<dd class="mt-1 text-xs text-[var(--muted-foreground)] tabular-nums" data-tile-delta={tile} />
</div>
))
}
</dl>

<div class="mt-4 grid gap-4 lg:grid-cols-2">
{
charts.map((chart) => (
<section class:list={['surface p-5', chart === 'instances' && 'lg:col-span-2']} aria-labelledby={`chart-${chart}-title`}>
<h2 id={`chart-${chart}-title`} class="text-base font-semibold">
{a.charts[chart].title}
</h2>
<p class="mt-1 text-sm text-[var(--muted-foreground)]">{a.charts[chart].caption}</p>
<div class="relative mt-4 h-72">
<canvas data-chart={chart} role="img" aria-label={a.charts[chart].title} />
</div>
<details class="js-table mt-3" data-table={chart}>
<summary class="cursor-pointer text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)]">
{a.showTable}
</summary>
<div class="mt-3 max-h-72 overflow-auto" />
</details>
</section>
))
}
</div>
</div>
</div>

<script>
import { initInsightsDashboard } from '../lib/insights-dashboard';

const root = document.getElementById('insights');
if (root) initInsightsDashboard(root);
</script>
66 changes: 64 additions & 2 deletions landing/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down Expand Up @@ -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' },
],
Expand Down Expand Up @@ -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?',
Expand Down Expand Up @@ -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 },
],
Expand All @@ -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:',
Expand Down Expand Up @@ -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: {
Expand Down
Loading
Loading