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:
1 parent
e69fbaf75e
commit
71190488ca
19 files changed
+751
-13
No files matched your search
@@ -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
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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_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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
+24
-1
@@ -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-scrobble-data: # persistent play-log DB (novasonic-plays.db)
|
||||
@@ -3,5 +3,6 @@
|
||||
cat > /usr/share/nginx/html/config.js <<EOF
|
||||
window.NS_SERVER_URL = "${NAVIDROME_URL:-}";
|
||||
window.NS_BACKEND_URL = "${BACKEND_URL:-}";
|
||||
window.NS_SCROBBLE_URL = "${SCROBBLE_URL:-}";
|
||||
EOF
|
||||
exec nginx -g "daemon off;"
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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})`));
|
||||
@@ -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}`);
|
||||
|
||||
+7
-3
@@ -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();
|
||||
}
|
||||
|
||||
+15
-7
@@ -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; };
|
||||
|
||||
|
||||
@@ -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
@@ -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: [],
|
||||
|
||||
Reference in new issue
Block a user