A full-stack MVP that adds a “channel surfing” experience on top of Navidrome.
- Navidrome remains the source of truth for music scanning and streaming.
- This app manages station definitions, station state, queue generation, play history, and feedback.
- Clients: web (Next.js) + mobile (Expo React Native).
- Monorepo:
pnpmworkspaces + Turborepo - Backend: Node.js + TypeScript + Fastify + Prisma + SQLite
- Shared contracts:
zodschemas inpackages/shared - Web: Next.js App Router
- Mobile: Expo + React Native +
expo-av
apps/
api/ Fastify API + Prisma schema + station generator + tests
web/ Next.js web client
mobile/ Expo mobile client
packages/
shared/ zod schemas + shared TS types- Configure Navidrome connection in app settings
- Import Navidrome library metadata into local
TrackCache - Create/edit/delete stations with rule-based filters:
- genre include/exclude
- artist include/exclude
- album include/exclude
- year range
- recently added window
- duration range
- avoid repeat hours
- artist separation tracks
- Structured station Rule Builder UI with live match preview
- Auto-generated system stations:
- Artist channels
- Genre channels
- Decade channels
- System station controls:
- regenerate from library metadata
- hide/unhide system stations
- enable/disable any station
- Radio tuner UX on web (
/radio) and mobile:- frequency-style channel labels
- dial/slider tuning
- seek step buttons
- scan mode (auto-seek stations every ~2s, not tracks)
- Tune-in mid-song offsets on station switch (
/play):- starts near the middle of tracks with configurable per-station rules
- returns
playback.startOffsetSecmetadata for clients
- Audio mode toggle on Radio screen (web + mobile):
Clean(unmodified)FM(mild radio coloration)AM(narrow-band vintage radio)
- Stateful playback per station:
- persistent recent tracks/artists window
- avoid repeat track window (default 24h)
- avoid same artist in recent N tracks (default 3)
- weighted preference for less-recently-played tracks and liked tracks
- Channel surfing: switch stations quickly without losing each station state
- Track feedback: like/dislike
- Play history endpoint
- Guide view: peek next tracks without advancing station state
- Navidrome-backed Last.fm scrobbling:
- now-playing update on track start
- submission scrobble on track change when listen time threshold is met
- App auth uses JWT sessions (
/api/auth/login) - Navidrome secrets:
- Preferred path implemented: store Subsonic token material (
token+salt) derived from password - Raw Navidrome password is not persisted in app DB
- Preferred path implemented: store Subsonic token material (
- Tradeoff:
- Stored token+salt can still authorize Subsonic requests for that account; protect DB and API access
- Node.js 20+
pnpm(via corepack)- Reachable Navidrome instance
- FFmpeg installed and available in PATH (or set
FFMPEG_PATH)
- Copy env files:
cp apps/api/.env.example apps/api/.env
cp apps/web/.env.example apps/web/.env
cp apps/mobile/.env.example apps/mobile/.env- Edit
apps/api/.env:
DATABASE_URL(SQLite path)JWT_SECRETAPP_LOGIN_EMAIL/APP_LOGIN_PASSWORD- optional Subsonic client metadata
- optional
FFMPEG_PATHif ffmpeg binary is not available asffmpeg - optional scrobble tuning:
SCROBBLE_ENABLEDSCROBBLE_MIN_LISTEN_SECONDSSCROBBLE_REQUIRED_PERCENTSCROBBLE_MAX_REQUIRED_SECONDS
- Edit
apps/web/.envandapps/mobile/.envAPI URL values if needed.
pnpm install
pnpm --filter @music-cable-box/api prisma:generate
pnpm --filter @music-cable-box/api prisma:push
pnpm devDefault local URLs:
- API:
http://localhost:4000 - Web:
http://localhost:3000 - Mobile: run via Expo (
pnpm dev:mobile)
docker compose up --buildNotes:
- Compose assumes
apps/api/.envandapps/web/.envexist. - Navidrome runs externally and should be reachable from the containers.
- Sign in using app credentials (
APP_LOGIN_EMAIL/APP_LOGIN_PASSWORD). - Open Settings and test/save Navidrome connection.
- Run library import.
- Create stations in Stations view.
- (Optional) Click
Generate Stationsto auto-create Artist/Genre/Decade system channels. - Tap
Surfto start playback. - Use
Next / Skipto advance and keep station state moving. - Use Like/Dislike to influence weighting.
- Open
Radiofor tuner-style station switching and scan mode.- Scan steps station-to-station every ~2 seconds until stopped.
- Open Guide page to preview upcoming tracks without advancing state.
POST /api/auth/loginGET /api/healthPOST /api/navidrome/test-connectionGET /api/settingsPATCH /api/settingsPOST /api/library/importGET /api/stations?includeHidden=true|falsePOST /api/stationsGET /api/stations/tunerPOST /api/stations/system/regenerateGET /api/stations/rule-options?field=genre|artist|album&q=&limit=GET /api/stations/preview?stationId=...POST /api/stations/previewGET /api/stations/:idPUT /api/stations/:idPATCH /api/stations/:id(toggleisEnabled; toggleisHiddenfor system stations)DELETE /api/stations/:idPOST /api/stations/:id/playPOST /api/stations/:id/nextPOST /api/tuner/stepGET /api/stream/:navidromeSongId?mode=&offsetSec=&format=&bitrateKbps=GET /api/stations/:id/peek?n=10POST /api/feedbackGET /api/history?stationId=&limit=
- Unit: station scoring logic
- Unit: exclusion behavior for recent track/artist rules
- Unit: decade bucketing + thresholds for auto-generation
- Integration: stations happy path (
create -> list -> play) - Integration: 50 sequential
nextcalls with no duplicates in the 24h repeat window - Integration: system station regeneration endpoint
Run:
pnpm --filter @music-cable-box/api testFor each next-track request:
- Load station rules and persisted station state.
- Build a dynamic SQL filter (
genre/artist/album includes/excludes,year,duration,recently added) and fetch a bounded candidate pool (~900rows max). - Exclude:
- tracks played in the station/user repeat window (default 24h)
- tracks in station recent-track state
- artists in the recent artist separation window (default 3)
- If strict exclusions empty the pool, relax in order:
- relax artist exclusion first
- then relax track exclusion only if required to avoid dead-end playback
- Score candidates with:
baseRandomin[0,1]recencyBoost = 1 - exp(-hoursSinceLastPlay / halfLifeHours)likeBoost = +0.5dislikePenalty = -1.0artistRepetitionPenaltyfor recent artists
- Sort by score, take top-K (
200), then weighted-random sample. - Persist station state and play event (
advancepath). - Return track metadata + proxied stream URL (
/api/stream/:songId) with mode context and playback metadata (startOffsetSec) for client-side tune-in.
peek runs the same logic in memory and does not persist station state.
Tune-in offset behavior (POST /api/stations/:id/play):
- Playback response includes:
playback.startOffsetSecplayback.reason(tune_in,manual, orresume)
- Offset is bounded by track duration and station tune-in rule settings.
POST /api/stations/:id/nextdoes not tune in mid-song by default (offset0).- Tune-in is controlled per station with rule fields:
tuneInEnabled(defaulttrue)tuneInMaxFraction(default0.6)tuneInMinHeadSec(default8)tuneInMinTailSec(default20)tuneInProbability(default0.9)
Audio mode behavior (server-side transcoding):
- Playback URLs from station endpoints point to
/api/stream/:songId. - The backend transcodes on the fly with FFmpeg so web and mobile hear the same mode.
- Modes:
UNMODIFIED: clean transcodeFM: gentle band-limit + compression + subtle noiseAM: mono narrow band + stronger compression + higher noise floor
- Mode is stored per user in
UserSettings.audioMode. - Radio screens load this setting at startup and let you switch
Clean / FM / AM. - When mode is changed during playback, clients rebuild the stream in the new mode and resume from the current timestamp (best-effort, approximate seek).
CPU note:
- FM/AM proxy streams use live FFmpeg processing and are more CPU-intensive than direct Navidrome passthrough.
- Use lower
bitrateKbpsin stream query or keep fewer concurrent listeners if needed.
Performance notes:
- Candidate pool is bounded and never loads full-library rows into memory.
- Track/play-feedback lookups are scoped to candidate IDs only.
- Short-lived per-station candidate cache reduces repeated SQL work under rapid skip/surf traffic.
Scrobbling behavior:
- The app calls Navidrome's Subsonic
scrobbleendpoint. - If you connected Last.fm inside Navidrome, Navidrome forwards these events to Last.fm.
- On track start (
/play,/next, tuner step): app sends now-playing (submission=false). - On track change (
/nextwithpreviousTrackId+listenSeconds): app submits scrobble (submission=true) when threshold passes. - Threshold defaults mirror common scrobble rules:
- minimum listen:
30s - required listen: max(
30s, min(50%of track,240s))
- minimum listen:
Use POST /api/stations/system/regenerate to create/update system channels from TrackCache.
- Artist: generates
Artist Radio: {Artist}for artists meeting threshold. - Genre: generates
Genre Radio: {Genre}for genres meeting threshold. - Decade: generates
{Decade} Radiofrom track years grouped by decade.
Default thresholds:
- artist:
15 - genre:
30 - decade:
50
Example:
curl -X POST http://localhost:4000/api/stations/system/regenerate \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"minTracks":{"artist":20,"genre":40,"decade":60}}'Stale system stations are preserved but auto-hidden (isHidden=true) rather than deleted, so history/state is retained.
GET /api/stations/tuner returns stations in stable tuner order with tunerIndex and cosmetic frequencyLabel.
Ordering:
- Non-hidden first
- System stations before user stations
- System type groups in this order:
GENRE,DECADE,ARTIST - Within group:
sortKeyascending
Frequency labels:
- FM-like range
88.1to107.9 - Base step
0.2 - If stations exceed available FM slots, frequencies are compressed across the full range
Hide/disable behavior:
isHiddenis supported for system stations (hide from default listings and tuner)isEnabledcan be toggled for any station
- Library import currently crawls artists/albums/songs sequentially; large libraries may take time.
- Mobile background playback is not fully tuned for production behavior.
- Track “rating” is modeled via like/dislike in MVP.
MIT (see LICENSE)