From 71190488ca466034d503bbce7bebc4384c27239e Mon Sep 17 00:00:00 2001 From: Honey Date: Thu, 6 Aug 2026 22:54:25 +0200 Subject: [PATCH] feat(scrobble): own timestamped play-log service (stats/Wrapped foundation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 1 + .../kotlin/com/novasonic/app/NovaSonicApp.kt | 8 + .../com/novasonic/app/data/MusicRepository.kt | 4 + .../com/novasonic/app/data/PlayLogClient.kt | 137 +++++++++++ .../com/novasonic/app/data/PrefsStore.kt | 4 + .../novasonic/app/player/PlayerController.kt | 1 + .../novasonic/app/ui/screen/SettingsScreen.kt | 8 + .../app/viewmodel/SettingsViewModel.kt | 30 +++ docker-compose.yml | 27 ++- docker-entrypoint.sh | 5 +- docs/scrobble-plan.md | 225 ++++++++++++++++++ public/sw.js | 2 +- scrobble/package.json | 12 + scrobble/scrobble-server.js | 179 ++++++++++++++ src/api.js | 8 + src/main.js | 10 +- src/player/player.js | 22 +- src/player/scrobbleLog.js | 85 +++++++ src/state.js | 2 +- 19 files changed, 754 insertions(+), 16 deletions(-) create mode 100644 android/app/src/main/kotlin/com/novasonic/app/data/PlayLogClient.kt create mode 100644 docs/scrobble-plan.md create mode 100644 scrobble/package.json create mode 100644 scrobble/scrobble-server.js create mode 100644 src/player/scrobbleLog.js diff --git a/.env.example b/.env.example index 5688e84..90ae0f6 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,7 @@ # Pre-fills the URLs on the setup screen NAVIDROME_URL=https://music.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 MUSIC_PATH=/srv/navidrome/music diff --git a/android/app/src/main/kotlin/com/novasonic/app/NovaSonicApp.kt b/android/app/src/main/kotlin/com/novasonic/app/NovaSonicApp.kt index 6c1c05e..53cbc9a 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/NovaSonicApp.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/NovaSonicApp.kt @@ -6,6 +6,10 @@ import com.novasonic.app.offline.OfflineRepository import com.novasonic.app.player.AbsPlayerController import com.novasonic.app.player.PlayerController 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() { @@ -27,6 +31,10 @@ class NovaSonicApp : Application() { playerController = PlayerController(applicationContext, repository) playerController.connect() absPlayerController = AbsPlayerController(applicationContext) + // Flush any plays queued while offline to the scrobble service. + CoroutineScope(Dispatchers.IO + SupervisorJob()).launch { + runCatching { repository.flushPlayLog() } + } } } diff --git a/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt b/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt index 07e5bfb..89d5e12 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt @@ -32,6 +32,7 @@ object ClientHolder { class MusicRepository(private val context: Context) { private val prefs = PrefsStore(context) + private val playLog = PlayLogClient(context) private suspend fun api(): SubsonicClient = 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 unstar(id: String) = api().unstar(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 getTopSongs(artistName: String) = api().getTopSongs(artistName) suspend fun getGenres() = api().getGenres() diff --git a/android/app/src/main/kotlin/com/novasonic/app/data/PlayLogClient.kt b/android/app/src/main/kotlin/com/novasonic/app/data/PlayLogClient.kt new file mode 100644 index 0000000..2b7313b --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/data/PlayLogClient.kt @@ -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) + + /** 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 = + runCatching { + if (!queueFile.exists()) emptyList() + else json.decodeFromString>(queueFile.readText()) + }.getOrDefault(emptyList()) + + private fun writeQueue(q: List) { + 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 } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt b/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt index 9371b28..76da7c3 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt @@ -18,6 +18,7 @@ class PrefsStore(private val context: Context) { val KEY_PASSWORD = stringPreferencesKey("password") val KEY_LASTFM_KEY = stringPreferencesKey("lastfm_key") val KEY_BACKEND_URL = stringPreferencesKey("backend_url") + val KEY_SCROBBLE_URL = stringPreferencesKey("scrobble_url") val KEY_SCROBBLE_MODE = stringPreferencesKey("scrobble_mode") val KEY_SHOW_COUNT = booleanPreferencesKey("show_play_count") val KEY_THEME = stringPreferencesKey("theme") @@ -40,6 +41,7 @@ class PrefsStore(private val context: Context) { val password: String = "", val lastFmKey: String = "", val backendUrl: String = "", + val scrobbleUrl: String = "", val scrobbleMode: String = "30s", val showPlayCount: Boolean = false, val theme: String = "dark-ocean", @@ -70,6 +72,7 @@ class PrefsStore(private val context: Context) { password = prefs[KEY_PASSWORD] ?: "", lastFmKey = prefs[KEY_LASTFM_KEY] ?: "", backendUrl = prefs[KEY_BACKEND_URL] ?: "", + scrobbleUrl = prefs[KEY_SCROBBLE_URL] ?: "", scrobbleMode = prefs[KEY_SCROBBLE_MODE] ?: "30s", showPlayCount = prefs[KEY_SHOW_COUNT] ?: false, theme = prefs[KEY_THEME] ?: "dark-ocean", @@ -104,6 +107,7 @@ class PrefsStore(private val context: Context) { prefs[KEY_PASSWORD] = settings.password prefs[KEY_LASTFM_KEY] = settings.lastFmKey prefs[KEY_BACKEND_URL] = settings.backendUrl + prefs[KEY_SCROBBLE_URL] = settings.scrobbleUrl prefs[KEY_SCROBBLE_MODE] = settings.scrobbleMode prefs[KEY_SHOW_COUNT] = settings.showPlayCount prefs[KEY_THEME] = settings.theme diff --git a/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt b/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt index 54c3ef1..a333f39 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt @@ -245,6 +245,7 @@ class PlayerController(private val context: Context, private val repo: MusicRepo val delayMs = runCatching { repo.getScrobbleDelayMs(durationMs) }.getOrDefault(30_000L) delay(delayMs) runCatching { repo.scrobble(song.id) } + runCatching { repo.logPlay(song) } // our own play-log, same counted moment } } diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt index 765b39e..24e997b 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt @@ -186,6 +186,14 @@ fun SettingsScreen() { ) 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)) // ── 🌐 Navidrome Server ────────────────────────────────────────────── diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SettingsViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SettingsViewModel.kt index b95243c..8d31278 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SettingsViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SettingsViewModel.kt @@ -28,6 +28,7 @@ data class SettingsUiState( val password: String = "", val lastFmKey: String = "", val backendUrl: String = "", + val scrobbleUrl: String = "", val scrobbleMode: String = "30s", val showPlayCount: Boolean = false, val theme: String = "dark-ocean", @@ -39,6 +40,7 @@ data class SettingsUiState( val error: String? = null, val absStatus: ConnStatus = ConnStatus.Idle, val backendStatus: ConnStatus = ConnStatus.Idle, + val scrobbleStatus: ConnStatus = ConnStatus.Idle, ) class SettingsViewModel(app: Application) : AndroidViewModel(app) { @@ -57,6 +59,7 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) { password = s.password, lastFmKey = s.lastFmKey, backendUrl = s.backendUrl, + scrobbleUrl = s.scrobbleUrl, scrobbleMode = s.scrobbleMode, showPlayCount = s.showPlayCount, 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 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 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 onShowPlayCount(v: Boolean) = update { it.copy(showPlayCount = v, savedOk = false) } 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() { val s = _state.value if (s.serverUrl.isBlank() || s.username.isBlank() || s.password.isBlank()) { @@ -139,6 +168,7 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) { password = s.password, lastFmKey = s.lastFmKey.trim(), backendUrl = s.backendUrl.trimEnd('/'), + scrobbleUrl = s.scrobbleUrl.trimEnd('/'), scrobbleMode = s.scrobbleMode, showPlayCount = s.showPlayCount, theme = s.theme, diff --git a/docker-compose.yml b/docker-compose.yml index 13729cc..3f1e681 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,10 +10,11 @@ services: environment: - NAVIDROME_URL=${NAVIDROME_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) ───────────────────────────────────────── novasonic-backend: - image: node:20-slim + image: node:22-slim container_name: novasonic-backend restart: unless-stopped working_dir: /app @@ -41,5 +42,27 @@ services: 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: - 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) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 2ff9196..a0f7204 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,7 +1,8 @@ #!/bin/sh # Generate runtime config from env vars so the frontend knows the Navidrome URL cat > /usr/share/nginx/html/config.js < 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. +``` diff --git a/public/sw.js b/public/sw.js index 594c816..9f065bc 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'ns-v63'; +const CACHE = 'ns-v64'; const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg']; const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js']; // Auth token params stripped from cache keys so artwork survives across sessions/token refreshes. diff --git a/scrobble/package.json b/scrobble/package.json new file mode 100644 index 0000000..e7f8dc2 --- /dev/null +++ b/scrobble/package.json @@ -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" + } +} diff --git a/scrobble/scrobble-server.js b/scrobble/scrobble-server.js new file mode 100644 index 0000000..7dc11f5 --- /dev/null +++ b/scrobble/scrobble-server.js @@ -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})`)); diff --git a/src/api.js b/src/api.js index d8b0664..4c45374 100644 --- a/src/api.js +++ b/src/api.js @@ -7,6 +7,14 @@ export function apiUrl(method, extra='') { 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}`; } +// 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='') { const res = await fetch(apiUrl(method, extra)); if(!res.ok) throw new Error(`HTTP ${res.status}`); diff --git a/src/main.js b/src/main.js index 37ededa..193c44c 100644 --- a/src/main.js +++ b/src/main.js @@ -2,6 +2,7 @@ import { S } from './state.js'; import { toast } from './utils.js'; import { api, fetchRomajiIndex, loadRomajiOverrides } from './api.js'; import { injectPWA } from './pwa.js'; +import { initPlayLog } from './player/scrobbleLog.js'; import { loadSavedTheme } from './themes.js'; import { initScrollRows } from './ui/components.js'; import { @@ -59,17 +60,18 @@ async function doConnect() { const _prev = (() => { try { return JSON.parse(localStorage.getItem('ns_cfg') || '{}'); } catch { return {}; } })(); 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 scrobUrl = (_prev.scrobbleUrl || window.NS_SCROBBLE_URL?.trim() || '').replace(/\/$/,''); if(!url || !user || !pass) { _showSetupError('Bitte Server-URL, Benutzernamen und Passwort ausfüllen.'); return; } const btn = document.getElementById('btn-connect'); btn.disabled=true; btn.textContent='Verbinde…'; 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 { await api('ping'); localStorage.setItem('ns_cfg', JSON.stringify(S.cfg)); startApp(); } catch(e) { - S.cfg = {url:'', user:'', pass:'', lastfmKey:'', backendUrl:''}; + S.cfg = {url:'', user:'', pass:'', lastfmKey:'', backendUrl:'', scrobbleUrl:''}; _showSetupError('Verbindung fehlgeschlagen: '+e.message); btn.disabled=false; btn.textContent='Verbinden'; } @@ -80,7 +82,7 @@ function disconnect() { localStorage.removeItem('ns_cfg'); localStorage.removeItem('ns_last_played'); 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.audio.pause(); S.audio.src=''; document.getElementById('app').style.display='none'; @@ -107,6 +109,7 @@ async function startApp() { navigate('home'); } 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 loadRomajiOverrides().catch(() => {}).then(() => { _restoreLastPlayed(); }); if(localStorage.getItem('ns_radio')==='1') { S.radioMode=true; _applyRadioBtn(); } @@ -132,6 +135,7 @@ window.addEventListener('load', () => { if(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.scrobbleUrl && window.NS_SCROBBLE_URL?.trim()) S.cfg.scrobbleUrl = window.NS_SCROBBLE_URL.trim().replace(/\/$/,''); startApp(); updateNpColorMode(); } diff --git a/src/player/player.js b/src/player/player.js index dde188b..346a6bf 100644 --- a/src/player/player.js +++ b/src/player/player.js @@ -4,6 +4,7 @@ import { api, streamUrl, coverUrl, downloadUrl, dispTitle, dispArtist, dispArtis // views.js/ui.js circular imports — safe (only used inside function bodies) import { loadRecommendations } from '../views/views.js'; import { updateLyricsHighlight, onNpSongChange, syncNowPlaying } from './nowplaying.js'; +import { logPlay } from './scrobbleLog.js'; // ─── NATIVE BRIDGE DETECTION ─────────────────────────────────────────────── // 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 ───────────────────────────────────────────────────────────── let _scrobbleTimer = null; let _scrobbleId = null; -function _scheduleScrobble(id, durSec) { - if (_scrobbleId === String(id)) return; +function _scheduleScrobble(s) { + const id = s?.id; + if (!id || _scrobbleId === String(id)) return; _cancelScrobble(); _scrobbleId = String(id); const mode = S.cfg.scrobbleMode || '15s'; if (mode === 'end') return; // fired explicitly in onEnded / onQueueEnded let delay; - const dur = (durSec || 0) * 1000; + const dur = (s?.duration || 0) * 1000; switch (mode) { case '30s': delay = 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; } _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; }, delay); } @@ -45,7 +50,10 @@ function _cancelScrobble() { _scrobbleId = null; } 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 // getNowPlaying (and e.g. a Discord rich-presence plugin) updates instantly. This is distinct @@ -214,7 +222,7 @@ window.NSPlayer = { try { const s = S.queue[S.qIdx]; if (!s) return; - _scheduleScrobble(s.id, s.duration); + _scheduleScrobble(s); if (S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue(); } catch (e) {} }, @@ -411,7 +419,7 @@ export function playCurrent() { onNpSongChange(s.id); renderQueue(); _scrobbleNowPlaying(s); // "läuft gerade" sofort an Navidrome (getNowPlaying) - _scheduleScrobble(s.id, s.duration); + _scheduleScrobble(s); const _resetFlag = () => { if (_playGen === gen) S._trackChanging = false; }; diff --git a/src/player/scrobbleLog.js b/src/player/scrobbleLog.js new file mode 100644 index 0000000..637807a --- /dev/null +++ b/src/player/scrobbleLog.js @@ -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); +} diff --git a/src/state.js b/src/state.js index 8f5bdd4..9688fca 100644 --- a/src/state.js +++ b/src/state.js @@ -1,6 +1,6 @@ // Shared mutable state — imported as { S } in every module 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 queue: [],