feat(scrobble): own timestamped play-log service (stats/Wrapped foundation)

Phase 1+2 of a self-hosted, multi-user listening archive — the basis for
day/month/year stats and a yearly "Wrapped". Runs IN ADDITION to the existing
Navidrome/Last.fm scrobble; never replaces it.

Service (isolated, own container):
- scrobble/scrobble-server.js — node:sqlite play-log (own volume), routes
  POST /scrobble/plays (idempotent by uuid), GET /scrobble/history (archive),
  DELETE /scrobble/plays?user= (test cleanup), GET /scrobble/health.
  Multi-user auth via Navidrome ping.view (cached) -> user not spoofable.
- docker-compose: novasonic-scrobble service (node:22, port 3710) + volume;
  backend bumped node:20->22 (EOL); SCROBBLE_URL plumbing (entrypoint, .env.example).

Capture (both platforms, offline-safe queue, idempotent batch flush):
- Fires at the existing scrobbleMode counted-play moment — consistent with
  Last.fm, respects the user's setting (no separate 50%/4min rule).
- Web: src/player/scrobbleLog.js (localStorage queue) + subsonicAuthParams()
  in api.js + hooks in player.js + flush init in main.js; S.cfg.scrobbleUrl.
- Android: data/PlayLogClient.kt (filesDir queue, reused Ktor client) +
  repo.logPlay/flushPlayLog + PlayerController hook + NovaSonicApp flush +
  scrobbleUrl in PrefsStore/SettingsViewModel/SettingsScreen (with test button).

Plan + design decisions documented in docs/scrobble-plan.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-08-06 22:54:25 +02:00
1 parent e69fbaf75e
commit 71190488ca
19 files changed
+754 -16

No files matched your search

+1
View File
@@ -3,6 +3,7 @@
# Pre-fills the URLs on the setup screen # Pre-fills the URLs on the setup screen
NAVIDROME_URL=https://music.yourdomain.com NAVIDROME_URL=https://music.yourdomain.com
BACKEND_URL=https://novasonic-api.yourdomain.com BACKEND_URL=https://novasonic-api.yourdomain.com
SCROBBLE_URL=https://novasonic-scrobble.yourdomain.com # play-log / stats service (port 3710)
# Absolute path to your Navidrome music library on the host # Absolute path to your Navidrome music library on the host
MUSIC_PATH=/srv/navidrome/music MUSIC_PATH=/srv/navidrome/music
@@ -6,6 +6,10 @@ import com.novasonic.app.offline.OfflineRepository
import com.novasonic.app.player.AbsPlayerController import com.novasonic.app.player.AbsPlayerController
import com.novasonic.app.player.PlayerController import com.novasonic.app.player.PlayerController
import com.novasonic.app.player.PreviewController import com.novasonic.app.player.PreviewController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class NovaSonicApp : Application() { class NovaSonicApp : Application() {
@@ -27,6 +31,10 @@ class NovaSonicApp : Application() {
playerController = PlayerController(applicationContext, repository) playerController = PlayerController(applicationContext, repository)
playerController.connect() playerController.connect()
absPlayerController = AbsPlayerController(applicationContext) absPlayerController = AbsPlayerController(applicationContext)
// Flush any plays queued while offline to the scrobble service.
CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
runCatching { repository.flushPlayLog() }
}
} }
} }
@@ -32,6 +32,7 @@ object ClientHolder {
class MusicRepository(private val context: Context) { class MusicRepository(private val context: Context) {
private val prefs = PrefsStore(context) private val prefs = PrefsStore(context)
private val playLog = PlayLogClient(context)
private suspend fun api(): SubsonicClient = private suspend fun api(): SubsonicClient =
ClientHolder.get(prefs.credentials.first()) ClientHolder.get(prefs.credentials.first())
@@ -56,6 +57,9 @@ class MusicRepository(private val context: Context) {
suspend fun star(id: String) = api().star(id) suspend fun star(id: String) = api().star(id)
suspend fun unstar(id: String) = api().unstar(id) suspend fun unstar(id: String) = api().unstar(id)
suspend fun scrobble(id: String) = api().scrobble(id) suspend fun scrobble(id: String) = api().scrobble(id)
// Our own timestamped play-log (stats + Wrapped), in addition to the Navidrome scrobble above.
suspend fun logPlay(song: Song) = playLog.logPlay(song)
suspend fun flushPlayLog() = playLog.flush()
suspend fun getArtistInfo(id: String) = api().getArtistInfo(id) suspend fun getArtistInfo(id: String) = api().getArtistInfo(id)
suspend fun getTopSongs(artistName: String) = api().getTopSongs(artistName) suspend fun getTopSongs(artistName: String) = api().getTopSongs(artistName)
suspend fun getGenres() = api().getGenres() suspend fun getGenres() = api().getGenres()
@@ -0,0 +1,137 @@
package com.novasonic.app.data
import android.content.Context
import com.novasonic.app.api.model.Song
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.encodeURLParameter
import io.ktor.http.isSuccess
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
import java.security.MessageDigest
import java.util.UUID
import kotlin.random.Random
/**
* Records timestamped plays into NovaSonic's own scrobble service (separate origin,
* settings.scrobbleUrl) — the basis for day/month/year stats + Wrapped.
*
* Runs IN ADDITION to the existing Navidrome scrobble (playCount + Last.fm); never
* replaces it. Called at the same "counted play" moment as the Navidrome scrobble
* (see PlayerController.scheduleScrobble).
*
* Offline-safe: entries persist to a JSON file and flush as an idempotent batch
* (the service dedups by uuid), so a play made offline is delivered later exactly once.
*
* One instance lives on MusicRepository → a single reused HttpClient (never per-call).
*/
class PlayLogClient(context: Context) {
private val prefs = PrefsStore(context)
private val queueFile = File(context.filesDir, "play-queue.json")
private val mutex = Mutex()
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private val http = HttpClient(Android) {
install(HttpTimeout) { requestTimeoutMillis = 15_000; connectTimeoutMillis = 8_000 }
}
@Serializable
data class PlayEntry(
val uuid: String,
val songId: String,
val title: String? = null,
val artist: String? = null,
val artistId: String? = null,
val album: String? = null,
val albumId: String? = null,
val duration: Int? = null,
val playedAt: Long,
val source: String = "android",
)
@Serializable
private data class Batch(val plays: List<PlayEntry>)
/** Queue one counted play and try to deliver it. */
suspend fun logPlay(song: Song) {
val s = prefs.appSettings.first()
if (s.scrobbleUrl.isBlank()) return
val entry = PlayEntry(
uuid = UUID.randomUUID().toString(),
songId = song.id,
title = song.title,
artist = song.artist,
artistId = song.artistId,
album = song.album,
albumId = song.albumId,
duration = song.duration,
playedAt = System.currentTimeMillis() / 1000,
)
mutex.withLock {
val q = readQueue().toMutableList()
q.add(entry)
writeQueue(q.takeLast(MAX_QUEUE))
}
flush(s)
}
/** Deliver everything queued as one idempotent batch. Safe to call repeatedly. */
suspend fun flush(settings: PrefsStore.AppSettings? = null) {
val s = settings ?: prefs.appSettings.first()
val base = s.scrobbleUrl.trimEnd('/')
if (base.isBlank() || s.username.isBlank() || s.password.isBlank()) return
val batch = mutex.withLock { readQueue() }
if (batch.isEmpty()) return
val auth = authParams(s.username, s.password)
val ok = runCatching {
val resp: HttpResponse = http.post("$base/scrobble/plays?$auth") {
contentType(ContentType.Application.Json)
setBody(json.encodeToString(Batch(batch)))
}
resp.status.isSuccess()
}.getOrDefault(false)
if (ok) {
// Remove exactly what we sent (accepted + duplicates are durable), preserving
// any plays queued while the request was in flight.
val sent = batch.map { it.uuid }.toHashSet()
mutex.withLock { writeQueue(readQueue().filterNot { it.uuid in sent }) }
}
}
private fun readQueue(): List<PlayEntry> =
runCatching {
if (!queueFile.exists()) emptyList()
else json.decodeFromString<List<PlayEntry>>(queueFile.readText())
}.getOrDefault(emptyList())
private fun writeQueue(q: List<PlayEntry>) {
runCatching { queueFile.writeText(json.encodeToString(q)) }
}
private fun authParams(user: String, pass: String): String {
val salt = Random.nextInt(100_000, 999_999).toString()
val token = md5(pass + salt)
return "u=${user.encodeURLParameter()}&t=$token&s=$salt"
}
private fun md5(input: String): String =
MessageDigest.getInstance("MD5").digest(input.toByteArray())
.joinToString("") { "%02x".format(it) }
companion object { private const val MAX_QUEUE = 500 }
}
@@ -18,6 +18,7 @@ class PrefsStore(private val context: Context) {
val KEY_PASSWORD = stringPreferencesKey("password") val KEY_PASSWORD = stringPreferencesKey("password")
val KEY_LASTFM_KEY = stringPreferencesKey("lastfm_key") val KEY_LASTFM_KEY = stringPreferencesKey("lastfm_key")
val KEY_BACKEND_URL = stringPreferencesKey("backend_url") val KEY_BACKEND_URL = stringPreferencesKey("backend_url")
val KEY_SCROBBLE_URL = stringPreferencesKey("scrobble_url")
val KEY_SCROBBLE_MODE = stringPreferencesKey("scrobble_mode") val KEY_SCROBBLE_MODE = stringPreferencesKey("scrobble_mode")
val KEY_SHOW_COUNT = booleanPreferencesKey("show_play_count") val KEY_SHOW_COUNT = booleanPreferencesKey("show_play_count")
val KEY_THEME = stringPreferencesKey("theme") val KEY_THEME = stringPreferencesKey("theme")
@@ -40,6 +41,7 @@ class PrefsStore(private val context: Context) {
val password: String = "", val password: String = "",
val lastFmKey: String = "", val lastFmKey: String = "",
val backendUrl: String = "", val backendUrl: String = "",
val scrobbleUrl: String = "",
val scrobbleMode: String = "30s", val scrobbleMode: String = "30s",
val showPlayCount: Boolean = false, val showPlayCount: Boolean = false,
val theme: String = "dark-ocean", val theme: String = "dark-ocean",
@@ -70,6 +72,7 @@ class PrefsStore(private val context: Context) {
password = prefs[KEY_PASSWORD] ?: "", password = prefs[KEY_PASSWORD] ?: "",
lastFmKey = prefs[KEY_LASTFM_KEY] ?: "", lastFmKey = prefs[KEY_LASTFM_KEY] ?: "",
backendUrl = prefs[KEY_BACKEND_URL] ?: "", backendUrl = prefs[KEY_BACKEND_URL] ?: "",
scrobbleUrl = prefs[KEY_SCROBBLE_URL] ?: "",
scrobbleMode = prefs[KEY_SCROBBLE_MODE] ?: "30s", scrobbleMode = prefs[KEY_SCROBBLE_MODE] ?: "30s",
showPlayCount = prefs[KEY_SHOW_COUNT] ?: false, showPlayCount = prefs[KEY_SHOW_COUNT] ?: false,
theme = prefs[KEY_THEME] ?: "dark-ocean", theme = prefs[KEY_THEME] ?: "dark-ocean",
@@ -104,6 +107,7 @@ class PrefsStore(private val context: Context) {
prefs[KEY_PASSWORD] = settings.password prefs[KEY_PASSWORD] = settings.password
prefs[KEY_LASTFM_KEY] = settings.lastFmKey prefs[KEY_LASTFM_KEY] = settings.lastFmKey
prefs[KEY_BACKEND_URL] = settings.backendUrl prefs[KEY_BACKEND_URL] = settings.backendUrl
prefs[KEY_SCROBBLE_URL] = settings.scrobbleUrl
prefs[KEY_SCROBBLE_MODE] = settings.scrobbleMode prefs[KEY_SCROBBLE_MODE] = settings.scrobbleMode
prefs[KEY_SHOW_COUNT] = settings.showPlayCount prefs[KEY_SHOW_COUNT] = settings.showPlayCount
prefs[KEY_THEME] = settings.theme prefs[KEY_THEME] = settings.theme
@@ -245,6 +245,7 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
val delayMs = runCatching { repo.getScrobbleDelayMs(durationMs) }.getOrDefault(30_000L) val delayMs = runCatching { repo.getScrobbleDelayMs(durationMs) }.getOrDefault(30_000L)
delay(delayMs) delay(delayMs)
runCatching { repo.scrobble(song.id) } runCatching { repo.scrobble(song.id) }
runCatching { repo.logPlay(song) } // our own play-log, same counted moment
} }
} }
@@ -186,6 +186,14 @@ fun SettingsScreen() {
) )
ConnStatusRow(status = state.backendStatus, label = "Nicht konfiguriert", onTest = vm::testBackend) ConnStatusRow(status = state.backendStatus, label = "Nicht konfiguriert", onTest = vm::testBackend)
NsTextField(
value = state.scrobbleUrl, onValueChange = vm::onScrobbleUrl,
label = "NovaSonic Scrobble URL",
hint = "Play-Log für Statistiken (Port 3710)",
keyboardType = KeyboardType.Uri,
)
ConnStatusRow(status = state.scrobbleStatus, label = "Nicht konfiguriert", onTest = vm::testScrobble)
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
// ── 🌐 Navidrome Server ────────────────────────────────────────────── // ── 🌐 Navidrome Server ──────────────────────────────────────────────
@@ -28,6 +28,7 @@ data class SettingsUiState(
val password: String = "", val password: String = "",
val lastFmKey: String = "", val lastFmKey: String = "",
val backendUrl: String = "", val backendUrl: String = "",
val scrobbleUrl: String = "",
val scrobbleMode: String = "30s", val scrobbleMode: String = "30s",
val showPlayCount: Boolean = false, val showPlayCount: Boolean = false,
val theme: String = "dark-ocean", val theme: String = "dark-ocean",
@@ -39,6 +40,7 @@ data class SettingsUiState(
val error: String? = null, val error: String? = null,
val absStatus: ConnStatus = ConnStatus.Idle, val absStatus: ConnStatus = ConnStatus.Idle,
val backendStatus: ConnStatus = ConnStatus.Idle, val backendStatus: ConnStatus = ConnStatus.Idle,
val scrobbleStatus: ConnStatus = ConnStatus.Idle,
) )
class SettingsViewModel(app: Application) : AndroidViewModel(app) { class SettingsViewModel(app: Application) : AndroidViewModel(app) {
@@ -57,6 +59,7 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
password = s.password, password = s.password,
lastFmKey = s.lastFmKey, lastFmKey = s.lastFmKey,
backendUrl = s.backendUrl, backendUrl = s.backendUrl,
scrobbleUrl = s.scrobbleUrl,
scrobbleMode = s.scrobbleMode, scrobbleMode = s.scrobbleMode,
showPlayCount = s.showPlayCount, showPlayCount = s.showPlayCount,
theme = s.theme, theme = s.theme,
@@ -73,6 +76,7 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
fun onPassword(v: String) = update { it.copy(password = v, savedOk = false) } fun onPassword(v: String) = update { it.copy(password = v, savedOk = false) }
fun onLastFmKey(v: String) = update { it.copy(lastFmKey = v, savedOk = false) } fun onLastFmKey(v: String) = update { it.copy(lastFmKey = v, savedOk = false) }
fun onBackendUrl(v: String) = update { it.copy(backendUrl = v, savedOk = false, backendStatus = ConnStatus.Idle) } fun onBackendUrl(v: String) = update { it.copy(backendUrl = v, savedOk = false, backendStatus = ConnStatus.Idle) }
fun onScrobbleUrl(v: String) = update { it.copy(scrobbleUrl = v, savedOk = false, scrobbleStatus = ConnStatus.Idle) }
fun onScrobbleMode(v: String) = update { it.copy(scrobbleMode = v, savedOk = false) } fun onScrobbleMode(v: String) = update { it.copy(scrobbleMode = v, savedOk = false) }
fun onShowPlayCount(v: Boolean) = update { it.copy(showPlayCount = v, savedOk = false) } fun onShowPlayCount(v: Boolean) = update { it.copy(showPlayCount = v, savedOk = false) }
fun onAbsUrl(v: String) = update { it.copy(absUrl = v, savedOk = false, absStatus = ConnStatus.Idle) } fun onAbsUrl(v: String) = update { it.copy(absUrl = v, savedOk = false, absStatus = ConnStatus.Idle) }
@@ -124,6 +128,31 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
} }
} }
fun testScrobble() {
val url = _state.value.scrobbleUrl.trim().trimEnd('/')
if (url.isBlank()) {
update { it.copy(scrobbleStatus = ConnStatus.Err("Scrobble URL eingeben")) }
return
}
update { it.copy(scrobbleStatus = ConnStatus.Checking) }
viewModelScope.launch {
try {
val http = HttpClient(Android) {
install(HttpTimeout) { requestTimeoutMillis = 5_000; connectTimeoutMillis = 4_000 }
}
val resp = http.get("$url/scrobble/health")
http.close()
if (resp.status == HttpStatusCode.OK) {
update { it.copy(scrobbleStatus = ConnStatus.Ok("Verbunden · $url")) }
} else {
update { it.copy(scrobbleStatus = ConnStatus.Err("HTTP ${resp.status.value}")) }
}
} catch (e: Exception) {
update { it.copy(scrobbleStatus = ConnStatus.Err("Nicht erreichbar")) }
}
}
}
fun save() { fun save() {
val s = _state.value val s = _state.value
if (s.serverUrl.isBlank() || s.username.isBlank() || s.password.isBlank()) { if (s.serverUrl.isBlank() || s.username.isBlank() || s.password.isBlank()) {
@@ -139,6 +168,7 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
password = s.password, password = s.password,
lastFmKey = s.lastFmKey.trim(), lastFmKey = s.lastFmKey.trim(),
backendUrl = s.backendUrl.trimEnd('/'), backendUrl = s.backendUrl.trimEnd('/'),
scrobbleUrl = s.scrobbleUrl.trimEnd('/'),
scrobbleMode = s.scrobbleMode, scrobbleMode = s.scrobbleMode,
showPlayCount = s.showPlayCount, showPlayCount = s.showPlayCount,
theme = s.theme, theme = s.theme,
+25 -2
View File
@@ -10,10 +10,11 @@ services:
environment: environment:
- NAVIDROME_URL=${NAVIDROME_URL} # pre-filled on the setup screen - NAVIDROME_URL=${NAVIDROME_URL} # pre-filled on the setup screen
- BACKEND_URL=${BACKEND_URL} # pre-filled on the setup screen - BACKEND_URL=${BACKEND_URL} # pre-filled on the setup screen
- SCROBBLE_URL=${SCROBBLE_URL} # play-log / stats service (setup screen)
# ── Backend (yt-dlp download server) ───────────────────────────────────────── # ── Backend (yt-dlp download server) ─────────────────────────────────────────
novasonic-backend: novasonic-backend:
image: node:20-slim image: node:22-slim
container_name: novasonic-backend container_name: novasonic-backend
restart: unless-stopped restart: unless-stopped
working_dir: /app working_dir: /app
@@ -41,5 +42,27 @@ services:
cd /app && NODE_PATH=/deps/node_modules node novasonic-server.js cd /app && NODE_PATH=/deps/node_modules node novasonic-server.js
" "
# ── Scrobble service (isolated timestamped play-log → stats + Wrapped) ────────
novasonic-scrobble:
image: node:22-slim
container_name: novasonic-scrobble
restart: unless-stopped
working_dir: /app
volumes:
- ./scrobble:/app:ro # server code (read-only)
- novasonic-scrobble-data:/data # own DB volume, isolated from downloads
ports:
- "3710:3710" # route this port through your reverse proxy
environment:
- PORT=3710
- DATA_PATH=/data
- NAVIDROME_URL=${NAVIDROME_URL} # for ping.view auth verification
command: >
sh -c "
mkdir -p /deps && cd /deps && npm install express &&
cd /app && NODE_PATH=/deps/node_modules node --experimental-sqlite scrobble-server.js
"
volumes: volumes:
novasonic-data: # persistent storage for romaji overrides and app state novasonic-data: # persistent storage for romaji overrides and app state
novasonic-scrobble-data: # persistent play-log DB (novasonic-plays.db)
+3 -2
View File
@@ -1,7 +1,8 @@
#!/bin/sh #!/bin/sh
# Generate runtime config from env vars so the frontend knows the Navidrome URL # Generate runtime config from env vars so the frontend knows the Navidrome URL
cat > /usr/share/nginx/html/config.js <<EOF cat > /usr/share/nginx/html/config.js <<EOF
window.NS_SERVER_URL = "${NAVIDROME_URL:-}"; window.NS_SERVER_URL = "${NAVIDROME_URL:-}";
window.NS_BACKEND_URL = "${BACKEND_URL:-}"; window.NS_BACKEND_URL = "${BACKEND_URL:-}";
window.NS_SCROBBLE_URL = "${SCROBBLE_URL:-}";
EOF EOF
exec nginx -g "daemon off;" exec nginx -g "daemon off;"
+225
View File
@@ -0,0 +1,225 @@
# NovaSonic Scrobble & Listening-Archive — Plan
> Status: **Planung** (kein Code). Eigenes, zeitgestempeltes Play-Log als Grundlage für
> Tages-/Monats-/Jahres-Statistiken und ein „Wrapped", **multi-user**, Web + Android.
> Ersetzt Koito/Spindle-Ideen (beide effektiv single-user).
## Warum überhaupt eigenes Log
- Navidrome speichert nur `playCount` + `playDate` (letztes Datum) — **keine Historie mit
Zeitstempeln**. Damit sind „Plays pro Tag/Monat/Jahr" und ein Jahres-Wrapped **nicht** möglich.
- **Koito** und **Spindle** sind praktisch **single-user** (ein Admin/DEFAULT_USER, eine Historie).
Für mehrere Navidrome-Nutzer im Haushalt passt keins.
- NovaSonic **ist der Player** → kennt bei jedem Abspielen: **wer + Song + wann + wie lange**.
Das ist der natürliche Erfassungspunkt, pro Nutzer.
---
## Architektur-Entscheidung: eigener Container statt im Download-Backend
**Empfehlung: separater, schlanker Service `novasonic-scrobble`.**
Die zwei Workloads haben **gegensätzliche Profile**:
| | Download-Backend | Scrobble-Service |
|---|---|---|
| Last | schwer, bursty (yt-dlp/ffmpeg/spotdl-Subprozesse) | leicht, konstant (wenige Writes/Song) |
| Uptime-Bedarf | egal, darf abstürzen/neustarten | **hoch** — darf keine Plays verlieren |
| Neustart-Kadenz | **regelmäßig** (zieht yt-dlp bei jedem Start neu) | selten |
| Kritikalität der Daten | reproduzierbar (erneut downloaden) | **Archiv, nicht reproduzierbar** |
| Deps | python3, ffmpeg, git, kuromoji … | nur `node:sqlite` (0 native Deps) |
Ein `yt-dlp`/`ffmpeg`-OOM oder der Routine-Neustart „yt-dlp neu ziehen" darf **nie** in-flight
Scrobbles verlieren oder die DB gefährden. Isolation = Datensicherheit. Der Scrobble-Service
startet in Sekunden (winziges Image), läuft stabil durch, besitzt sein eigenes DB-Volume.
**Kosten:** eine zusätzliche Compose-Service + ein Upstream in der nginx-Config. Gering, weil der
Service trivial ist (Express + SQLite + ~4 Routen).
### Alternative (verworfen): alles im Download-Backend
Weniger Dateien, aber koppelt das **Archiv** an den instabilsten/am häufigsten neugestarteten
Prozess. Für einen reinen MVP machbar, aber die Datenintegrität ist es nicht wert.
### Node-/SQLite-Wahl für den neuen Service
Neues Image auf **`node:22-slim`** → eingebautes **`node:sqlite`** (stabil ab Node 22), **keine
native Build-Kette**, kein `better-sqlite3`-Kompilat. Das bestehende Download-Backend bleibt auf
`node:20-slim` unangetastet.
---
## Datenmodell (SQLite, im eigenen Volume)
DB-Datei: `/data/novasonic-plays.db` (eigenes `novasonic-scrobble-data`-Volume).
**Denormalisiert** — Titel/Artist/Album werden mitgespeichert, damit das Archiv überlebt, wenn ein
Song später aus Navidrome verschwindet (Kern eines Archivs).
```sql
CREATE TABLE plays (
id INTEGER PRIMARY KEY,
user TEXT NOT NULL, -- Navidrome-Username → Mandanten-Trennung
song_id TEXT NOT NULL, -- Subsonic id (kann später ungültig werden)
title TEXT,
artist TEXT,
artist_id TEXT,
album TEXT,
album_id TEXT,
duration INTEGER, -- Track-Länge in Sekunden
played_at INTEGER NOT NULL, -- Unix-Epoch, UTC
source TEXT, -- 'web' | 'android'
uuid TEXT UNIQUE -- Idempotenz (Offline-Retry doppelt-sicher)
);
CREATE INDEX ix_plays_user_time ON plays(user, played_at);
CREATE INDEX ix_plays_user_artist ON plays(user, artist_id);
CREATE INDEX ix_plays_user_song ON plays(user, song_id);
```
- **`uuid UNIQUE`**: Web/Android queuen Plays offline und flushen später → ohne Idempotenz zählt ein
Retry doppelt. Client erzeugt die UUID; Ingest ignoriert Duplikate (`INSERT OR IGNORE`).
- **Zeitzone**: immer **UTC** speichern; beim Aggregieren die lokale TZ des Users anwenden
(Client schickt Offset/IANA-TZ), sonst rutscht ein Play um Mitternacht in den falschen Tag.
- **Rollups**: bewusst **keine** materialisierten Tabellen zu Beginn — On-the-fly `GROUP BY` reicht
für persönliche Datenmengen locker (100k Zeilen sind für SQLite nichts). Nachrüstbar.
---
## Was zählt als „Play"
**Umgesetzt (besser als der ursprüngliche 50%/4-min-Plan):** Wir docken am **bestehenden
`scrobbleMode`-Moment** an — genau dann, wenn NovaSonic ohnehin den echten Navidrome-Scrobble
absendet (nicht das `submission=false`-„läuft gerade"), feuert **zusätzlich** `logPlay()`.
Vorteile: (1) **konsistent mit Last.fm** — beide zählen denselben Play zur selben Zeit;
(2) respektiert die **vom User gewählte** Scrobble-Schwelle (15s / 30s / 1/3 / 2/3 / end) statt
einer fest verdrahteten Regel; (3) eine einzige Wahrheit, weniger Code. Der Play-Log ersetzt den
Navidrome/Last.fm-Scrobble **nie** — er läuft parallel.
Web: [src/player/player.js](../src/player/player.js) `_scheduleScrobble`/`_scrobbleNow`.
Android: [PlayerController.kt](../android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt) `scheduleScrobble`.
---
## API (Service `novasonic-scrobble`)
| Endpoint | Zweck | Status |
|---|---|---|
| `POST /scrobble/plays` | Batch-Ingest `{plays:[…]}`, idempotent per `uuid` | ✅ Phase 1 |
| `GET /scrobble/history?limit=&before=` | Roh-Verlauf chronologisch = **das Archiv** | ✅ Phase 1 |
| `DELETE /scrobble/plays?user=NAME` | Zeilen eines Users löschen (Test-Cleanup) | ✅ Phase 1 |
| `GET /scrobble/health` | Liveness + Zeilen-Zahl | ✅ Phase 1 |
| `GET /scrobble/stats?range=day\|month\|year&from=&to=&tz=` | Aggregate: Top-Artists/Alben/Tracks, Summe Plays + Hörzeit, Zeit-Buckets für Charts | Phase 3 |
| `GET /scrobble/wrapped?year=YYYY&tz=` | Fertiger Wrapped-Payload | Phase 4 |
### Routing: eigene Origin, KEIN nginx-Upstream
Der Frontend-nginx serviert nur Static und **proxyt nichts** — der Client spricht das Download-Backend
direkt über `S.cfg.backendUrl` (`window.NS_BACKEND_URL`) an. Der Scrobble-Service folgt exakt diesem
Muster: eigene Origin `S.cfg.scrobbleUrl` (`window.NS_SCROBBLE_URL`, aus `SCROBBLE_URL` in der
`.env` → `docker-entrypoint.sh`). CORS `*` wie im Backend. Der User routet Port **3710** über seinen
Reverse-Proxy (wie 8080/3700).
### Auth / Multi-User (Trust-Modell)
Jeder Call trägt die Subsonic-Token-Creds, die NovaSonic ohnehin nutzt (`u`, `t=md5(pass+salt)`,
`s=salt`). Der Service verifiziert den User einmal per günstigem Subsonic **`ping.view`** gegen
Navidrome (5 min gecacht) → der `user`-Wert im Play ist damit echt, nicht spoofbar. Kein neues
Login, kein neues Passwort.
### Aggregation Tag / Monat / Jahr (das Archiv-Herzstück)
```sql
-- Plays pro Tag im Zeitraum (User + lokale TZ via Offset-Sekunden)
SELECT strftime('%Y-%m-%d', played_at + :tzOffset, 'unixepoch') AS bucket,
COUNT(*) AS plays, SUM(duration) AS seconds
FROM plays
WHERE user = :user AND played_at BETWEEN :from AND :to
GROUP BY bucket ORDER BY bucket;
```
Gleiche Query mit `%Y-%m` → Monat, `%Y` → Jahr. Top-Listen analog via `GROUP BY artist_id` etc.
### Wrapped-Payload (Beispiel-Felder)
`year`, `totalPlays`, `totalMinutes`, `topArtists[]`, `topAlbums[]`, `topTracks[]`,
`mostActiveDay`, `listeningClock` (Plays je Stunde 0–23), `monthlyBreakdown[12]`,
`newArtistsDiscovered` (erste je Artist im Jahr), `streakLongest`.
---
## Erfassung im Client
- **Web** ([src/api.js](../src/api.js) / Player): im `timeupdate`/`ended`-Handler die 50 %/4-min-
Schwelle prüfen → lokale Queue (localStorage) → Flush an `/scrobble/plays`. Läuft **parallel** zum
bestehenden Navidrome-Scrobble (Play-Count bleibt unangetastet).
- **Android** (Media3-Listener): gleiche Schwelle über Position/State → Room-Queue → Flush.
Nutzt dieselbe Offline-Queue-Denke wie das bestehende Offline-System.
Beide: Queue + Retry, damit ein Play auch offline erfasst und später idempotent nachgereicht wird.
---
## UI
- **Web:** neue View „Statistiken" — Kalender-Heatmap (Tage), Monats-Balken, Top-Listen; **Wrapped**
als Story-Seite zum Jahresende. Vanilla SVG/Canvas, **keine Chart-Lib** (Bundle schlank halten).
- **Android:** Stats-Screen (Compose), spiegelbildlich zur Web-View.
---
## Optional später: ListenBrainz-Relay
Jeden Play zusätzlich an einen **ListenBrainz-kompatiblen** Endpoint schieben (falls konfiguriert)
→ NovaSonic wird nebenbei ein Standard-Scrobbler, Koito/LB als externes Ziel andockbar. Nicht
Teil des Kerns.
---
## docker-compose (Skizze)
```yaml
novasonic-scrobble:
image: node:22-slim
container_name: novasonic-scrobble
restart: unless-stopped
working_dir: /app
volumes:
- ./scrobble:/app:ro
- novasonic-scrobble-data:/data
ports:
- "3710:3710"
environment:
- PORT=3710
- DATA_PATH=/data
- NAVIDROME_URL=${NAVIDROME_URL} # für ping.view-Verifikation
command: >
sh -c "mkdir -p /deps && cd /deps && npm install express &&
cd /app && NODE_PATH=/deps/node_modules node scrobble-server.js"
volumes:
novasonic-scrobble-data:
```
`.env`: `SCROBBLE_URL=https://scrobble.deine-domain` ergänzen; der Frontend-Container injiziert das als
`window.NS_SCROBBLE_URL` (via `docker-entrypoint.sh`). Kein nginx-Upstream nötig — eigene Origin, CORS `*`.
---
## Phasen
1. ✅ **Service-Fundament** *(erledigt)*: `scrobble/scrobble-server.js` (node:22 + `node:sqlite`),
`POST /scrobble/plays` (idempotent) + `GET /scrobble/history` + `DELETE /scrobble/plays` +
`GET /scrobble/health`, `ping.view`-Auth (5 min gecacht), eigenes Volume, Compose-Service,
`SCROBBLE_URL`-Plumbing (entrypoint + `S.cfg.scrobbleUrl`), Backend auf node:22 hochgezogen.
Lokal end-to-end getestet (Ingest/Idempotenz/History/Purge). *Ab hier wird bereits archiviert.*
2. ✅ **Erfassung Web + Android** *(erledigt)*: Play-Log am `scrobbleMode`-Moment (s.o.),
Offline-Queue (Web: localStorage `ns_play_queue`; Android: `filesDir/play-queue.json`),
idempotenter Batch-Flush bei Start + `online`-Event. `SCROBBLE_URL`/`scrobbleUrl` in beiden
Settings. Web-Build + Android-`compileDebugKotlin` grün. *Ab jetzt wird real erfasst.*
3. **`/scrobble/stats`** + Web-Statistik-View (Heatmap, Monats-Balken, Top-Listen).
4. **`/scrobble/wrapped`** + Story-Seite; Android-Stats-Screen.
5. *(optional)* ListenBrainz-Relay.
---
## Offene Entscheidungen
- **Container-Netzwerk/Proxy:** eigener Port (3710) hinter nginx-Upstream `/scrobble/` — bestätigen.
- **TZ-Übergabe:** fester UTC-Offset pro Request vs. IANA-Name (Sommerzeit-genau). Start: Offset,
später IANA falls DST-Grenzfälle stören.
- **Schwelle:** 50 %/4 min (Last.fm) übernehmen oder anpassen?
- **Aufbewahrung:** unbegrenzt (Archiv). Backup = das SQLite-File im Volume sichern.
```
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'ns-v63'; const CACHE = 'ns-v64';
const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg']; const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg'];
const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js']; const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js'];
// Auth token params stripped from cache keys so artwork survives across sessions/token refreshes. // Auth token params stripped from cache keys so artwork survives across sessions/token refreshes.
+12
View File
@@ -0,0 +1,12 @@
{
"name": "novasonic-scrobble",
"private": true,
"description": "Isolated timestamped play-log service for NovaSonic (stats + Wrapped).",
"engines": { "node": ">=22" },
"scripts": {
"start": "node --experimental-sqlite scrobble-server.js"
},
"dependencies": {
"express": "^4.18.0"
}
}
+179
View File
@@ -0,0 +1,179 @@
/**
* NovaSonic Scrobble Service
* ---------------------------
* A tiny, isolated service that records a timestamped, per-user play-log — the
* foundation for day/month/year listening stats and a yearly "Wrapped".
*
* Deliberately SEPARATE from the download backend: the download server is heavy
* (yt-dlp/ffmpeg subprocesses), may crash, and restarts regularly (to re-pull
* yt-dlp). This archive needs high uptime + data integrity, so it runs on its own.
*
* Storage: node:sqlite (built into Node 22; run with --experimental-sqlite).
* DB file: {DATA_PATH}/novasonic-plays.db (own persistent volume)
*
* Auth / multi-user: every request carries the same Subsonic token creds the PWA
* already uses (u, t=md5(pass+salt), s=salt). We verify them against Navidrome's
* ping.view once (cached briefly) → the `user` we store is real, not spoofable.
* No new login, no new password.
*
* Phase 1 routes:
* POST /scrobble/plays ingest a batch, idempotent by uuid
* GET /scrobble/history raw chronological log (= the archive)
* DELETE /scrobble/plays?user=NAME purge one user's rows (test cleanup)
* GET /scrobble/health liveness + total row count
*/
const { DatabaseSync } = require('node:sqlite');
const express = require('express');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const PORT = process.env.PORT || 3710;
const DATA_PATH = process.env.DATA_PATH || '/data';
const NAVIDROME_URL = (process.env.NAVIDROME_URL || '').replace(/\/$/, '');
if (!NAVIDROME_URL) console.warn('⚠ NAVIDROME_URL not set — auth verification will reject everything.');
// ─── DB ─────────────────────────────────────────────────────────────────────
fs.mkdirSync(DATA_PATH, { recursive: true });
const db = new DatabaseSync(path.join(DATA_PATH, 'novasonic-plays.db'));
db.exec('PRAGMA journal_mode = WAL;'); // survive restarts / concurrent reads
db.exec(`
CREATE TABLE IF NOT EXISTS plays (
id INTEGER PRIMARY KEY,
user TEXT NOT NULL,
song_id TEXT NOT NULL,
title TEXT,
artist TEXT,
artist_id TEXT,
album TEXT,
album_id TEXT,
duration INTEGER,
played_at INTEGER NOT NULL, -- Unix epoch, UTC
source TEXT, -- 'web' | 'android'
uuid TEXT UNIQUE -- idempotency for offline-retry
);
`);
db.exec('CREATE INDEX IF NOT EXISTS ix_plays_user_time ON plays(user, played_at);');
db.exec('CREATE INDEX IF NOT EXISTS ix_plays_user_artist ON plays(user, artist_id);');
db.exec('CREATE INDEX IF NOT EXISTS ix_plays_user_song ON plays(user, song_id);');
const _insert = db.prepare(`
INSERT OR IGNORE INTO plays
(user, song_id, title, artist, artist_id, album, album_id, duration, played_at, source, uuid)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const _history = db.prepare(`
SELECT id, song_id, title, artist, artist_id, album, album_id, duration, played_at, source
FROM plays WHERE user = ? AND played_at < ? ORDER BY played_at DESC LIMIT ?
`);
const _purge = db.prepare('DELETE FROM plays WHERE user = ?');
const _count = db.prepare('SELECT COUNT(*) AS n FROM plays');
// ─── Auth (Navidrome ping.view, cached) ─────────────────────────────────────
const _authCache = new Map(); // "u|t|s" -> { ok, exp }
const _AUTH_TTL = 5 * 60 * 1000;
async function verifyUser(req) {
const u = req.query.u || req.body?.u;
const t = req.query.t || req.body?.t;
const s = req.query.s || req.body?.s;
if (!u || !t || !s || !NAVIDROME_URL) return null;
const key = `${u}|${t}|${s}`;
const now = Date.now();
const cached = _authCache.get(key);
if (cached && cached.exp > now) return cached.ok ? u : null;
try {
const url = `${NAVIDROME_URL}/rest/ping.view?u=${encodeURIComponent(u)}&t=${encodeURIComponent(t)}&s=${encodeURIComponent(s)}&v=1.16.1&c=NovaSonic&f=json`;
const r = await fetch(url, { signal: AbortSignal.timeout(8000) });
const j = await r.json();
const ok = j?.['subsonic-response']?.status === 'ok';
_authCache.set(key, { ok, exp: now + _AUTH_TTL });
return ok ? u : null;
} catch {
return null;
}
}
// ─── App ────────────────────────────────────────────────────────────────────
const app = express();
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
res.setHeader('Access-Control-Allow-Private-Network', 'true');
if (req.method === 'OPTIONS') { res.sendStatus(204); return; }
next();
});
app.use(express.json({ limit: '2mb' }));
// Normalize a client timestamp to Unix seconds (accepts s or ms; defaults to now)
function _toEpochSec(v) {
const n = Number(v);
if (!Number.isFinite(n) || n <= 0) return Math.floor(Date.now() / 1000);
return n > 1e12 ? Math.floor(n / 1000) : Math.floor(n);
}
// POST /scrobble/plays — ingest a batch { plays: [ {...} ] }, idempotent by uuid
app.post('/scrobble/plays', async (req, res) => {
const user = await verifyUser(req);
if (!user) return res.status(401).json({ error: 'unauthorized' });
const plays = Array.isArray(req.body?.plays) ? req.body.plays : [];
if (!plays.length) return res.json({ accepted: 0, duplicates: 0 });
let accepted = 0, duplicates = 0, invalid = 0;
for (const p of plays) {
if (!p || !p.songId) { invalid++; continue; }
const uuid = String(p.uuid || crypto.randomUUID());
try {
const r = _insert.run(
user,
String(p.songId),
p.title ?? null,
p.artist ?? null,
p.artistId ?? null,
p.album ?? null,
p.albumId ?? null,
Number.isFinite(Number(p.duration)) ? Math.floor(Number(p.duration)) : null,
_toEpochSec(p.playedAt),
p.source === 'android' ? 'android' : 'web',
uuid
);
if (r.changes > 0) accepted++; else duplicates++;
} catch { invalid++; }
}
res.json({ accepted, duplicates, invalid });
});
// GET /scrobble/history?limit=&before= — raw chronological log (the archive)
app.get('/scrobble/history', async (req, res) => {
const user = await verifyUser(req);
if (!user) return res.status(401).json({ error: 'unauthorized' });
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 100, 1), 500);
const before = _toEpochSec(req.query.before || Date.now());
const rows = _history.all(user, before, limit);
res.json({ user, count: rows.length, plays: rows });
});
// DELETE /scrobble/plays?user=NAME — purge one user's rows (for test cleanup).
// Requires ANY valid Navidrome login (a deleted user can't authenticate as itself,
// so cleanup after deletion must be authorized by a currently-valid account).
app.delete('/scrobble/plays', async (req, res) => {
const caller = await verifyUser(req);
if (!caller) return res.status(401).json({ error: 'unauthorized' });
const target = req.query.user || caller;
const r = _purge.run(String(target));
res.json({ purged: r.changes, user: target });
});
// GET /scrobble/health — liveness + total rows (no auth)
app.get('/scrobble/health', (_req, res) => {
res.json({ ok: true, plays: _count.get().n, navidrome: !!NAVIDROME_URL });
});
app.listen(PORT, () => console.log(`✅ NovaSonic scrobble service on :${PORT} (data: ${DATA_PATH})`));
+8
View File
@@ -7,6 +7,14 @@ export function apiUrl(method, extra='') {
const token = md5(S.cfg.pass + salt); const token = md5(S.cfg.pass + salt);
return `${S.cfg.url}/rest/${method}?u=${encodeURIComponent(S.cfg.user)}&t=${token}&s=${salt}&v=1.16.1&c=NovaSonic&f=json${extra}`; return `${S.cfg.url}/rest/${method}?u=${encodeURIComponent(S.cfg.user)}&t=${token}&s=${salt}&v=1.16.1&c=NovaSonic&f=json${extra}`;
} }
// Bare Subsonic token-auth params (u=..&t=..&s=..) for talking to non-Navidrome NovaSonic
// services (e.g. the scrobble play-log), which verify the same creds via ping.view.
export function subsonicAuthParams() {
if (!S.cfg.pass || !S.cfg.user) return '';
const salt = Math.random().toString(36).slice(2, 10);
const token = md5(S.cfg.pass + salt);
return `u=${encodeURIComponent(S.cfg.user)}&t=${token}&s=${salt}`;
}
export async function api(method, extra='') { export async function api(method, extra='') {
const res = await fetch(apiUrl(method, extra)); const res = await fetch(apiUrl(method, extra));
if(!res.ok) throw new Error(`HTTP ${res.status}`); if(!res.ok) throw new Error(`HTTP ${res.status}`);
+7 -3
View File
@@ -2,6 +2,7 @@ import { S } from './state.js';
import { toast } from './utils.js'; import { toast } from './utils.js';
import { api, fetchRomajiIndex, loadRomajiOverrides } from './api.js'; import { api, fetchRomajiIndex, loadRomajiOverrides } from './api.js';
import { injectPWA } from './pwa.js'; import { injectPWA } from './pwa.js';
import { initPlayLog } from './player/scrobbleLog.js';
import { loadSavedTheme } from './themes.js'; import { loadSavedTheme } from './themes.js';
import { initScrollRows } from './ui/components.js'; import { initScrollRows } from './ui/components.js';
import { import {
@@ -59,17 +60,18 @@ async function doConnect() {
const _prev = (() => { try { return JSON.parse(localStorage.getItem('ns_cfg') || '{}'); } catch { return {}; } })(); const _prev = (() => { try { return JSON.parse(localStorage.getItem('ns_cfg') || '{}'); } catch { return {}; } })();
const lfKey = document.getElementById('lastfm-key')?.value.trim() || _prev.lastfmKey || ''; const lfKey = document.getElementById('lastfm-key')?.value.trim() || _prev.lastfmKey || '';
const backUrl = (document.getElementById('backend-url')?.value.trim() || _prev.backendUrl || window.NS_BACKEND_URL?.trim() || '').replace(/\/$/,''); const backUrl = (document.getElementById('backend-url')?.value.trim() || _prev.backendUrl || window.NS_BACKEND_URL?.trim() || '').replace(/\/$/,'');
const scrobUrl = (_prev.scrobbleUrl || window.NS_SCROBBLE_URL?.trim() || '').replace(/\/$/,'');
if(!url || !user || !pass) { _showSetupError('Bitte Server-URL, Benutzernamen und Passwort ausfüllen.'); return; } if(!url || !user || !pass) { _showSetupError('Bitte Server-URL, Benutzernamen und Passwort ausfüllen.'); return; }
const btn = document.getElementById('btn-connect'); const btn = document.getElementById('btn-connect');
btn.disabled=true; btn.textContent='Verbinde…'; btn.disabled=true; btn.textContent='Verbinde…';
document.getElementById('setup-error').style.display='none'; document.getElementById('setup-error').style.display='none';
S.cfg = {url, user, pass, lastfmKey:lfKey, backendUrl:backUrl}; S.cfg = {url, user, pass, lastfmKey:lfKey, backendUrl:backUrl, scrobbleUrl:scrobUrl};
try { try {
await api('ping'); await api('ping');
localStorage.setItem('ns_cfg', JSON.stringify(S.cfg)); localStorage.setItem('ns_cfg', JSON.stringify(S.cfg));
startApp(); startApp();
} catch(e) { } catch(e) {
S.cfg = {url:'', user:'', pass:'', lastfmKey:'', backendUrl:''}; S.cfg = {url:'', user:'', pass:'', lastfmKey:'', backendUrl:'', scrobbleUrl:''};
_showSetupError('Verbindung fehlgeschlagen: '+e.message); _showSetupError('Verbindung fehlgeschlagen: '+e.message);
btn.disabled=false; btn.textContent='Verbinden'; btn.disabled=false; btn.textContent='Verbinden';
} }
@@ -80,7 +82,7 @@ function disconnect() {
localStorage.removeItem('ns_cfg'); localStorage.removeItem('ns_cfg');
localStorage.removeItem('ns_last_played'); localStorage.removeItem('ns_last_played');
sessionStorage.removeItem('ns_nav'); sessionStorage.removeItem('ns_nav');
S.cfg = {url:'', user:'', pass:'', lastfmKey:'', backendUrl:''}; S.cfg = {url:'', user:'', pass:'', lastfmKey:'', backendUrl:'', scrobbleUrl:''};
S.queue=[]; S.qIdx=-1; S._navStack=[]; S._navCursor=-1; S.queue=[]; S.qIdx=-1; S._navStack=[]; S._navCursor=-1;
S.audio.pause(); S.audio.src=''; S.audio.pause(); S.audio.src='';
document.getElementById('app').style.display='none'; document.getElementById('app').style.display='none';
@@ -107,6 +109,7 @@ async function startApp() {
navigate('home'); navigate('home');
} }
initNotifBell(); initNotifBell();
initPlayLog(); // flush any queued plays to the scrobble service (offline-safe)
// Wait for overrides before restoring player bar so disp* functions have the cache ready // Wait for overrides before restoring player bar so disp* functions have the cache ready
loadRomajiOverrides().catch(() => {}).then(() => { _restoreLastPlayed(); }); loadRomajiOverrides().catch(() => {}).then(() => { _restoreLastPlayed(); });
if(localStorage.getItem('ns_radio')==='1') { S.radioMode=true; _applyRadioBtn(); } if(localStorage.getItem('ns_radio')==='1') { S.radioMode=true; _applyRadioBtn(); }
@@ -132,6 +135,7 @@ window.addEventListener('load', () => {
if(saved) { if(saved) {
S.cfg = JSON.parse(saved); S.cfg = JSON.parse(saved);
if(!S.cfg.backendUrl && window.NS_BACKEND_URL?.trim()) S.cfg.backendUrl = window.NS_BACKEND_URL.trim().replace(/\/$/,''); if(!S.cfg.backendUrl && window.NS_BACKEND_URL?.trim()) S.cfg.backendUrl = window.NS_BACKEND_URL.trim().replace(/\/$/,'');
if(!S.cfg.scrobbleUrl && window.NS_SCROBBLE_URL?.trim()) S.cfg.scrobbleUrl = window.NS_SCROBBLE_URL.trim().replace(/\/$/,'');
startApp(); startApp();
updateNpColorMode(); updateNpColorMode();
} }
+15 -7
View File
@@ -4,6 +4,7 @@ import { api, streamUrl, coverUrl, downloadUrl, dispTitle, dispArtist, dispArtis
// views.js/ui.js circular imports — safe (only used inside function bodies) // views.js/ui.js circular imports — safe (only used inside function bodies)
import { loadRecommendations } from '../views/views.js'; import { loadRecommendations } from '../views/views.js';
import { updateLyricsHighlight, onNpSongChange, syncNowPlaying } from './nowplaying.js'; import { updateLyricsHighlight, onNpSongChange, syncNowPlaying } from './nowplaying.js';
import { logPlay } from './scrobbleLog.js';
// ─── NATIVE BRIDGE DETECTION ─────────────────────────────────────────────── // ─── NATIVE BRIDGE DETECTION ───────────────────────────────────────────────
// True when running inside the NovaSonic Android wrapper (NSBridge injected before loadUrl). // True when running inside the NovaSonic Android wrapper (NSBridge injected before loadUrl).
@@ -20,14 +21,15 @@ let _bridgeReady = false; // true once onReady has fired; onQueueEnded is ign
// ─── SCROBBLE ───────────────────────────────────────────────────────────── // ─── SCROBBLE ─────────────────────────────────────────────────────────────
let _scrobbleTimer = null; let _scrobbleTimer = null;
let _scrobbleId = null; let _scrobbleId = null;
function _scheduleScrobble(id, durSec) { function _scheduleScrobble(s) {
if (_scrobbleId === String(id)) return; const id = s?.id;
if (!id || _scrobbleId === String(id)) return;
_cancelScrobble(); _cancelScrobble();
_scrobbleId = String(id); _scrobbleId = String(id);
const mode = S.cfg.scrobbleMode || '15s'; const mode = S.cfg.scrobbleMode || '15s';
if (mode === 'end') return; // fired explicitly in onEnded / onQueueEnded if (mode === 'end') return; // fired explicitly in onEnded / onQueueEnded
let delay; let delay;
const dur = (durSec || 0) * 1000; const dur = (s?.duration || 0) * 1000;
switch (mode) { switch (mode) {
case '30s': delay = 30_000; break; case '30s': delay = 30_000; break;
case '1/3': delay = dur > 0 ? Math.floor(dur / 3) : 30_000; break; case '1/3': delay = dur > 0 ? Math.floor(dur / 3) : 30_000; break;
@@ -35,7 +37,10 @@ function _scheduleScrobble(id, durSec) {
default: delay = 15_000; break; default: delay = 15_000; break;
} }
_scrobbleTimer = setTimeout(() => { _scrobbleTimer = setTimeout(() => {
if (_scrobbleId) api('scrobble', `&id=${_scrobbleId}&time=${Date.now()}`).catch(() => {}); if (_scrobbleId) {
api('scrobble', `&id=${_scrobbleId}&time=${Date.now()}`).catch(() => {});
logPlay(s, HAS_BRIDGE ? 'android' : 'web'); // our own play-log, same counted moment
}
_scrobbleId = null; _scrobbleId = null;
}, delay); }, delay);
} }
@@ -45,7 +50,10 @@ function _cancelScrobble() {
_scrobbleId = null; _scrobbleId = null;
} }
function _scrobbleNow(s) { function _scrobbleNow(s) {
if (s) api('scrobble', `&id=${s.id}&time=${Date.now()}`).catch(() => {}); if (s) {
api('scrobble', `&id=${s.id}&time=${Date.now()}`).catch(() => {});
logPlay(s, HAS_BRIDGE ? 'android' : 'web'); // our own play-log, same counted moment
}
} }
// "Now Playing" ping (submission=false) — tells the server what's playing *right now* so // "Now Playing" ping (submission=false) — tells the server what's playing *right now* so
// getNowPlaying (and e.g. a Discord rich-presence plugin) updates instantly. This is distinct // getNowPlaying (and e.g. a Discord rich-presence plugin) updates instantly. This is distinct
@@ -214,7 +222,7 @@ window.NSPlayer = {
try { try {
const s = S.queue[S.qIdx]; const s = S.queue[S.qIdx];
if (!s) return; if (!s) return;
_scheduleScrobble(s.id, s.duration); _scheduleScrobble(s);
if (S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue(); if (S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue();
} catch (e) {} } catch (e) {}
}, },
@@ -411,7 +419,7 @@ export function playCurrent() {
onNpSongChange(s.id); onNpSongChange(s.id);
renderQueue(); renderQueue();
_scrobbleNowPlaying(s); // "läuft gerade" sofort an Navidrome (getNowPlaying) _scrobbleNowPlaying(s); // "läuft gerade" sofort an Navidrome (getNowPlaying)
_scheduleScrobble(s.id, s.duration); _scheduleScrobble(s);
const _resetFlag = () => { if (_playGen === gen) S._trackChanging = false; }; const _resetFlag = () => { if (_playGen === gen) S._trackChanging = false; };
+85
View File
@@ -0,0 +1,85 @@
// ─── NovaSonic play-log client ──────────────────────────────────────────────
// Records a timestamped play into our own scrobble service (separate origin,
// S.cfg.scrobbleUrl) — the foundation for day/month/year stats + Wrapped.
//
// This runs IN ADDITION to the existing Navidrome scrobble (which feeds playCount
// and, if configured, Last.fm). It never replaces it. We fire logPlay() at the
// exact moment NovaSonic decides a play "counts" (see player.js), so the play-log
// and Last.fm stay consistent and both honour the user's scrobbleMode setting.
//
// Offline-safe: entries are queued in localStorage and flushed as an idempotent
// batch (server dedups by uuid), so a play made offline is delivered later exactly
// once.
import { S } from '../state.js';
import { subsonicAuthParams } from '../api.js';
const _QUEUE_KEY = 'ns_play_queue';
const _MAX_QUEUE = 500; // guard against unbounded growth if the service stays down
let _flushing = false;
function _uuid() {
try { return crypto.randomUUID(); }
catch { return 'p-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10); }
}
function _load() {
try { return JSON.parse(localStorage.getItem(_QUEUE_KEY) || '[]'); } catch { return []; }
}
function _save(q) {
try { localStorage.setItem(_QUEUE_KEY, JSON.stringify(q.slice(-_MAX_QUEUE))); } catch {}
}
// Record one counted play. `song` is a Subsonic song object; source 'web' | 'android'.
export function logPlay(song, source = 'web') {
if (!S.cfg?.scrobbleUrl || !song?.id) return;
const entry = {
uuid: _uuid(),
songId: String(song.id),
title: song.title ?? null,
artist: song.artist ?? null,
artistId: song.artistId ?? null,
album: song.album ?? null,
albumId: song.albumId ?? null,
duration: Number.isFinite(Number(song.duration)) ? Math.floor(Number(song.duration)) : null,
playedAt: Math.floor(Date.now() / 1000), // Unix seconds, UTC
source,
};
const q = _load();
q.push(entry);
_save(q);
flushPlayQueue();
}
// Deliver everything queued as one idempotent batch. Safe to call repeatedly.
export async function flushPlayQueue() {
if (_flushing || !S.cfg?.scrobbleUrl) return;
const batch = _load();
if (!batch.length) return;
const auth = subsonicAuthParams();
if (!auth) return;
_flushing = true;
try {
const res = await fetch(`${S.cfg.scrobbleUrl}/scrobble/plays?${auth}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plays: batch }),
});
if (!res.ok) return; // 401/5xx/network → keep the queue, retry later
// Success: the batch is handled (accepted + duplicates are both durable; invalid
// entries are malformed and will never succeed). Remove exactly what we sent by
// uuid, preserving any plays queued while the request was in flight.
const sent = new Set(batch.map(e => e.uuid));
_save(_load().filter(e => !sent.has(e.uuid)));
} catch {
// network error → keep queue for the next flush
} finally {
_flushing = false;
}
}
// Retry on app start and whenever connectivity returns.
export function initPlayLog() {
flushPlayQueue();
window.addEventListener('online', flushPlayQueue);
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Shared mutable state — imported as { S } in every module // Shared mutable state — imported as { S } in every module
export const S = { export const S = {
cfg: { url: '', user: '', pass: '', lastfmKey: '', backendUrl: '', showPlayCount: false, showRomaji: false }, cfg: { url: '', user: '', pass: '', lastfmKey: '', backendUrl: '', scrobbleUrl: '', showPlayCount: false, showRomaji: false },
// Playback // Playback
queue: [], queue: [],