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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@
[![Windows dev build](https://github.com/rowkavdev/nowplaying/actions/workflows/beta.yml/badge.svg?branch=main)](https://github.com/rowkavdev/nowplaying/releases/tag/dev)
[![Coverage](https://codecov.io/gh/rowkavdev/nowplaying/graph/badge.svg?branch=main)](https://app.codecov.io/gh/rowkavdev/nowplaying)
[![License: AGPL-3.0](https://img.shields.io/github/license/rowkavdev/nowplaying)](LICENSE)
[![Cards shown](https://img.shields.io/endpoint?url=https%3A%2F%2Fnowplaying-hosted.vercel.app%2Fbadges%2Frequests.json)](docs/hosted-upload.md#usage-badges)
[![PCs registered](https://img.shields.io/endpoint?url=https%3A%2F%2Fnowplaying-hosted.vercel.app%2Fbadges%2Fpcs.json)](docs/hosted-upload.md#usage-badges)
[![People signed in](https://img.shields.io/endpoint?url=https%3A%2F%2Fnowplaying-hosted.vercel.app%2Fbadges%2Fusers.json)](docs/hosted-upload.md#usage-badges)

Your media server knows what you are playing. nowplaying turns that into a card for your GitHub README and a status in Discord, without exposing your server to the internet. It runs on your PC and reads playback from Plex, Jellyfin, Emby or Navidrome. Spotify and a paired YouTube extension can also feed the card.

| Studio · music | Editorial · film | Hi-Fi · TV |
| :---: | :---: | :---: |
| ![Studio: Blue Monday by New Order with sample cover art](docs/assets/cards/studio.svg) | ![Editorial: Dune Part Two with sample poster art](docs/assets/cards/editorial-film.svg) | ![Hi-Fi: Lost, The Constant, with sample poster art](docs/assets/cards/terminal-episode.svg) |
| **Classic · music** | **Paper · music** | **Compact · music** |
| ![Classic: Holocene by Bon Iver](docs/assets/cards/music-playing.svg) | ![Paper: Holocene by Bon Iver](docs/assets/cards/music-light.svg) | ![Compact: Holocene by Bon Iver](docs/assets/cards/compact.svg) |

Studio puts artwork behind the card. Editorial pairs a large cover with warm paper and serif type. Hi-Fi adds record grooves and an amber timeline. Classic, Paper and Compact are also available. **[Explore all six styles, with music, film and TV examples →](docs/card-examples.md)**
Studio puts artwork behind the card. Editorial pairs a large cover with warm paper and serif type. Hi-Fi adds record grooves and an amber timeline. Classic is the original dark card, Paper its light twin and Compact a single text-only line. **[See every style with music, film and TV examples →](docs/card-examples.md)**

*Examples rendered by the app with illustrated sample artwork. Your card uses the current album cover or poster; hosted covers require **Include cover art** in Settings.*

Expand Down
3 changes: 3 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Two audiences, two homes:
| [Windows packaging](windows-packaging.md) | Installer and portable ZIP layout, and why native files sit beside the EXE | Current |
| [Deployment](deployment.md) | Self-hosting design notes: network boundaries, polling, supervision, containers | Partly design target; the hosted card and Windows app sections predate what shipped |
| [Releases and versioning](releasing.md) | SemVer, immutable artifacts, checksums, provenance and migrations | Current |
| [Release notes](release-notes/) | Notes per release: changes, migration and compatibility | Current |
| [Security policy](../SECURITY.md) | Vulnerability reporting, deployment hardening, private data and secret handling | Current |

## Contributor docs
Expand All @@ -34,6 +35,8 @@ Two audiences, two homes:
| --- | --- | --- |
| [Architecture](architecture.md) | Module boundaries, data flow, errors and extension points | Current |
| [Contributing](../CONTRIBUTING.md) | Setup, style, testing, focused PRs, licensing and review checklist | Current |
| [Ways to help](contributing.md) | Where help is wanted, a worked Discord example and the pull request checklist | Current |
| [Guide skeleton](guide-skeleton.md) | Tested example paths for install, Plex, Discord and the hosted card, verified by `scripts/guide-paths.js` | Current |

## Project references

Expand Down
12 changes: 11 additions & 1 deletion docs/card-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,17 @@ A record display with circular artwork and grooves, monospace type and an amber

A dark card with blue progress.

![Classic card showing Holocene by Bon Iver](assets/cards/music-playing.svg)
**Music**

![Classic music card: Holocene by Bon Iver](assets/cards/music-playing.svg)

**Film**

![Classic film card: Spirited Away (2001)](assets/cards/movie-playing.svg)

**TV**

![Classic TV card: Lost, S04E05, The Constant](assets/cards/episode-playing.svg)

## Paper

Expand Down
20 changes: 19 additions & 1 deletion docs/hosted-upload.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,25 @@ Deploy the updated hosted service before enabling cover sharing in the updated d
## How it's stored

- The service keeps only the latest update from each of your PCs, and deletes it 10 minutes after the last one. Stop playing or close the app and the card goes back to "Not playing".
- The service's logs and counters never contain titles, artists, usernames or keys. The only counters are totals: cards shown, PCs registered and people signed in.
- The service's logs and counters never contain titles, artists, usernames or keys. The only counters are totals: cards shown, PCs registered and people signed in. Each total is also published as a public badge; see [Usage badges](#usage-badges).

## Usage badges

The service publishes three aggregate totals as [Shields endpoint badges](https://shields.io/badges/endpoint-badge), cached for five minutes:

| Badge | URL |
| --- | --- |
| Cards shown | `https://nowplaying-hosted.vercel.app/badges/requests.json` |
| PCs registered | `https://nowplaying-hosted.vercel.app/badges/pcs.json` |
| People signed in | `https://nowplaying-hosted.vercel.app/badges/users.json` |

Point a Shields endpoint badge at one of them, for example:

```md
![Cards shown](https://img.shields.io/endpoint?url=https%3A%2F%2Fnowplaying-hosted.vercel.app%2Fbadges%2Frequests.json)
```

Each badge is a single number with nothing per card, per PC or per person. If the counter is unavailable the endpoint answers `503`, so Shields shows its error state instead of a number. Self-hosted deployments can wire the same handlers and serve the same badges from their own domain.

### Signed in with GitHub (one card for all your PCs)

Expand Down
11 changes: 7 additions & 4 deletions docs/testing-dev-build.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,18 @@ Write down the build number and version from the release title, for example `dev
- [ ] "Hide progress and timer": no progress bar on the card and no timer on Discord.
- [ ] "Don't show" Music (or Movies / TV episodes): playing that kind clears Discord and the card shows nothing playing. Untick it and it comes back.
- [ ] Settings > Discord > "When nothing is playing": pick "Show what I played last", Save, stop playback. Discord keeps the last item instead of clearing.
- [ ] Settings > Card: move Corners, Padding and Width, and switch Style. The preview redraws within a second each time. With nothing playing it shows "Sample track".
- [ ] Settings → Appearance → Card Settings: pick **Paper** from the style picker, press Save, and reload the page. The picker still shows **Paper** and the preview is still white.
- [ ] Move Width, then Padding and Corners under "Fine-tune typography, artwork and layout". The preview redraws as each control moves. With nothing playing, the preview shows a sample track on illustrated artwork.
- [ ] Untick "Show progress bar". Bar thickness greys out and the bar goes from the preview. Pick Compact: the bar box unticks on its own.
- [ ] Type 90 in Width. A message says 280 to 800 and Save is greyed out. Put 520 back.
- [ ] Pick Light, 520 px, press Save. Open the card link from the Status page: the card is light and wider. Nothing restarted.
- [ ] Quit and reopen nowplaying. Settings > Card still shows Light and 520, and the card still looks the same.
- [ ] Pick **Paper**, 520 px, press Save. Open the card link from the Status page: the card is light and wider. Nothing restarted.
- [ ] Quit and reopen nowplaying. Card Settings still shows **Paper** and 520, and the card still looks the same.
- [ ] Pick each of the six styles in turn: Classic, Paper, Compact, Studio, Editorial, Hi-Fi. The preview redraws for each, and picking a style resets the look controls to that style's defaults and keeps the width.
- [ ] With the hosted card on, the README snippet under Hosted card ends in `?theme=paper&width=520`. Open that link and check it matches.
- [ ] In Sharing → Hosted Card Settings, tick **Include cover art**, press Save, and reload the page. The toggle stays on.
- [ ] Before or after the first server, **Settings > Spotify and hosted card > Hosted card** has **Sign in with GitHub**. Open the link and enter the code. The card link becomes `/u/<your-username>.svg`. Settings > Hosted card devices lists this PC; rename works and **Sign out** stops it updating.
- [ ] On an existing installation that has never used GitHub sign-in, open configured Settings and confirm the same button is visible without rerunning setup.
- [ ] "Back to defaults", then Save. The card goes back to dark, 440 px, and the hosted link has no `?` part.
- [ ] "Back to defaults", then Save. The card goes back to Classic, 440 px, and the hosted link has no `?` part.
- [ ] With artwork showing, the card background picks up the cover's colour. Set `card.artworkTint` to `false` in `config.json`: the background goes back to the plain theme colour.
- [ ] The status page lists every connected server, each with its own connection state.
- [ ] Settings > Media servers lists every server. Remove one there (not the last one); nowplaying restarts with the other servers still feeding the card and Discord.
Expand Down
2 changes: 1 addition & 1 deletion docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ curl --head 'https://cards.example/card.svg'

The current response includes `ETag` and `Cache-Control`. A `304 Not Modified` response means the client revalidated the same card. A `200 OK` with a new `ETag` means the origin changed. GitHub fetches README images through its Camo proxy, so an origin change may still take time to appear in a README.

Do not add tokens, usernames, media titles, private URLs or random cache-busting values to a public card URL. Query strings are logged by browsers, proxies and hosting platforms. Keep the documented card URL stable and wait for GitHub's cache to revalidate. Planned privacy-safe cache-state and data-age headers are tracked in [#118](https://github.com/rowkavdev/nowplaying/issues/118).
Do not add tokens, usernames, media titles, private URLs or random cache-busting values to a public card URL. Query strings are logged by browsers, proxies and hosting platforms. Keep the documented card URL stable and wait for GitHub's cache to revalidate. The response also includes diagnostic headers (`X-Nowplaying-Source`, `X-Nowplaying-Age`, `X-Nowplaying-Cache`, `X-Nowplaying-Provider`) for source, age, cache state and provider state; [hosted card deployment](hosted-card.md) explains what each value means.

## No active session appears

Expand Down
2 changes: 1 addition & 1 deletion docs/wiki/Advanced-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Settings live at `%LOCALAPPDATA%\nowplaying\config.json`. It contains no passwor
## What you can change

- Templates for the title, detail and state lines, with fields like `{title}`, `{series}`, `{episodeCode}` and `{year}`
- Card theme (`midnight-blue`, `paper`, `compact`), width (280-800 px) and which fields show
- Card theme (`midnight-blue`, `paper`, `compact`, `studio`, `editorial`, `terminal`), width (280-800 px) and which fields show
- `card.artworkTint` (default `true`): tint the card background from the cover's dominant colour; set `false` in `config.json` to turn it off
- Discord timestamps, idle behaviour and album art lookup (Discord wording, buttons and update interval can't be changed yet)
- Privacy mode and per-field hiding (see [Privacy and safe configuration](Privacy-and-safe-configuration))
Expand Down
2 changes: 1 addition & 1 deletion docs/wiki/Enable-Discord-Rich-Presence.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Discord Rich Presence puts "Listening to" or "Watching" with your current media

1. Connect a media server on the first-run page. Wait for the full Settings page, then under **Discord** tick **Show what I'm playing on Discord**. The first-run page has no Discord controls.
2. Pick what happens when nothing is playing: clear the status, clear it after a short wait, show "Nothing playing", or keep the last item.
3. Press **Save**. Play something to check the actual Discord status. **Settings > Card** has a card preview, not a Discord status preview.
3. Press **Save**. Play something to check the actual Discord status. **Settings → Appearance → Card Settings** has a card preview, not a Discord status preview.

Success looks like: play something, and your Discord profile shows it within a few seconds.

Expand Down
12 changes: 10 additions & 2 deletions hosted/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,17 @@ The device token is only stored as a SHA-256 hash. The card ID is random and can

## Storage and counters

State lives in Upstash Redis (Frankfurt, `eu-central-1`; functions run in `fra1` next to it) (`UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN`) with a TTL, so nothing depends on function memory. The counters are aggregate only. Totals: `np:stats:cards_rendered` and `np:stats:registrations`. Per UTC day, for the private usage dashboard (#218): `np:stats:day:<date>:renders` and `np:stats:day:<date>:registrations` (plain counts), `np:stats:day:<date>:devices`, a HyperLogLog of hashed device IDs, and `np:stats:day:<date>:active_devices`, a counter that goes up when that HyperLogLog sees a new device. Together they estimate how many devices sent updates that day, never which ones. The per-day keys expire after 400 days. A failing stats write never fails a request.
State lives in Upstash Redis (Frankfurt, `eu-central-1`; functions run in `fra1` next to it) (`UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN`) with a TTL, so nothing depends on function memory. The counters are aggregate only. Totals: `np:stats:cards_rendered`, `np:stats:registrations` and `np:stats:users`. Per UTC day, for the private usage dashboard (#218): `np:stats:day:<date>:renders`, `np:stats:day:<date>:registrations` and `np:stats:day:<date>:users` (plain counts), `np:stats:day:<date>:devices`, a HyperLogLog of hashed device IDs, and `np:stats:day:<date>:active_devices`, a counter that goes up when that HyperLogLog sees a new device. Together they estimate how many devices sent updates that day, never which ones. The per-day keys expire after 400 days. A failing stats write never fails a request.

`GET /badges/requests.json` publishes the `np:stats:cards_rendered` total as a [Shields endpoint badge](https://shields.io/badges/endpoint-badge) (label "card requests", cached for 5 minutes). It's a single public number with nothing per card or device. The README shows it with `https://img.shields.io/endpoint?url=https://nowplaying-hosted.vercel.app/badges/requests.json`.
Three totals are published as [Shields endpoint badges](https://shields.io/badges/endpoint-badge), cached for 5 minutes, each a single public number with nothing per card, user or device:

| Path | Counter | Label |
| --- | --- | --- |
| `GET /badges/requests.json` | `np:stats:cards_rendered` | "card requests" |
| `GET /badges/pcs.json` | `np:stats:registrations` | "PCs registered" |
| `GET /badges/users.json` | `np:stats:users` | "people signed in" |

An unavailable counter returns `503` with the message "unavailable" and `no-store`. See [usage badges](../docs/hosted-upload.md#usage-badges) for the shields.io snippet.

## Deploy

Expand Down
5 changes: 5 additions & 0 deletions hosted/api/badge-pcs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { handlers } from "../lib/default.js";

export default function handler(req, res) {
return handlers.pcsBadge(req, res);
}
5 changes: 5 additions & 0 deletions hosted/api/badge-users.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { handlers } from "../lib/default.js";

export default function handler(req, res) {
return handlers.usersBadge(req, res);
}
30 changes: 22 additions & 8 deletions hosted/lib/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,17 @@ export function createHandlers({ getService }) {
sendCard(req, res, presence, options);
} catch (error) { sendError(res, error); }
},
// Shields.io endpoint badge (https://shields.io/badges/endpoint-badge).
// Shields.io endpoint badges (https://shields.io/badges/endpoint-badge).
// Each publishes one aggregate total; the privacy rules in
// docs/hosted-upload.md keep these free of anything per user or device.
async requestsBadge(req, res) {
if (!requireMethod(req, res, ["GET", "HEAD"])) return;
try {
const count = await getService().readRequestCount();
sendJson(res, 200, { schemaVersion: 1, label: "card requests", message: compactCount(count), color: "#58a6ff" }, { "cache-control": "public, max-age=300, s-maxage=300" });
} catch {
sendJson(res, 503, { schemaVersion: 1, label: "card requests", message: "unavailable", color: "lightgrey" });
}
return badgeHandler(req, res, "card requests", () => getService().readRequestCount());
},
async pcsBadge(req, res) {
return badgeHandler(req, res, "PCs registered", () => getService().readRegistrationCount());
},
async usersBadge(req, res) {
return badgeHandler(req, res, "people signed in", () => getService().readUserCount());
},
health(req, res) {
res.statusCode = 200;
Expand All @@ -84,6 +86,18 @@ export function createHandlers({ getService }) {
};
}

// One Shields endpoint badge response for an aggregate counter read: a short
// cached 200 with the compact count, or a 503 the reader can ignore.
async function badgeHandler(req, res, label, readCount) {
if (!requireMethod(req, res, ["GET", "HEAD"])) return;
try {
const count = await readCount();
sendJson(res, 200, { schemaVersion: 1, label, message: compactCount(count), color: "#58a6ff" }, { "cache-control": "public, max-age=300, s-maxage=300" });
} catch {
sendJson(res, 503, { schemaVersion: 1, label, message: "unavailable", color: "lightgrey" });
}
}

// 999 -> "999", 12_345 -> "12.3k", 4_560_000 -> "4.56M".
export function compactCount(count) {
let value = count;
Expand Down
14 changes: 9 additions & 5 deletions hosted/lib/service.js
Original file line number Diff line number Diff line change
Expand Up @@ -445,14 +445,18 @@ export function createService({ redis, now = () => Date.now(), githubUser = crea
return shown(JSON.parse(raw));
}

// Public total for the README badge: how many card requests the service has
// answered. One aggregate number, nothing per card or per device.
async function readRequestCount() {
const raw = await cmd("GET", "np:stats:cards_rendered");
// Public totals for the README badges: one aggregate number each, nothing
// per card, per user or per device.
async function readTotalCount(key) {
const raw = await cmd("GET", key);
const count = raw === null ? 0 : Number(raw);
if (!Number.isSafeInteger(count) || count < 0) throw new ServiceError(503, "unavailable");
return count;
}

return { register, ingest, revoke, readCardState, readUserCardState, readRequestCount, signInWithGitHub, listDevices, renameDevice, removeDevice, signOutEverywhere };
function readRequestCount() { return readTotalCount("np:stats:cards_rendered"); }
function readRegistrationCount() { return readTotalCount("np:stats:registrations"); }
function readUserCount() { return readTotalCount("np:stats:users"); }

return { register, ingest, revoke, readCardState, readUserCardState, readRequestCount, readRegistrationCount, readUserCount, signInWithGitHub, listDevices, renameDevice, removeDevice, signOutEverywhere };
}
8 changes: 8 additions & 0 deletions hosted/vercel.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@
{
"source": "/badges/requests.json",
"destination": "/api/badge"
},
{
"source": "/badges/pcs.json",
"destination": "/api/badge-pcs"
},
{
"source": "/badges/users.json",
"destination": "/api/badge-users"
}
],
"headers": [
Expand Down
Loading
Loading