feat(android): Songs loeschen und Playlist aus dem Import, plus Web-Scrollfix

Android holt zwei Sachen nach, die es im Web laengst gibt:

- Song von der Festplatte loeschen, im Kontextmenue unten abgesetzt und rot, mit
  derselben Sicherheitsabfrage wie im Web. Laeuft ueber /api/delete-song und
  erscheint nur mit konfiguriertem Backend — Navidrome allein loescht nichts.
- Playlist direkt aus dem Import anlegen: Haekchen plus Namensfeld ab zwei
  Titeln, danach Scan anstossen, Titel suchen, Playlist aus den gefundenen.
  Nicht gefundene werden gezaehlt statt die Erstellung scheitern zu lassen.

Dazu der Web-Fix zur Album-Position: die gespeicherte Scrollposition wurde in
genau einem requestAnimationFrame gesetzt. Solange Cover und Eintraege noch
nachluden, war scrollHeight kleiner als das Ziel und der Browser kappte den Wert
stillschweigend — man landete wieder oben. Jetzt wird er ueber bis zu ~0,5 s
erneut gesetzt, bis er sitzt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-04 23:40:57 +02:00
1 parent a05c15df93
commit aae358c4c7
12 files changed
+1031 -12

No files matched your search

+20 -9
View File
@@ -36,6 +36,8 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
| Playlist create/edit | ✅ | ✅ | | Playlist create/edit | ✅ | ✅ |
| Playlist cover upload | ✅ | ✅ | | Playlist cover upload | ✅ | ✅ |
| Spotify / YouTube importer | ✅ | ✅ | | Spotify / YouTube importer | ✅ | ✅ |
| Playlist aus Import anlegen | ✅ | ✅ |
| Song von Festplatte löschen | ✅ | ✅ |
| Metadata editor (tags + cover) | ✅ | ❌ | | Metadata editor (tags + cover) | ✅ | ❌ |
| Song delete (from disk) | ✅ | ❌ | | Song delete (from disk) | ✅ | ❌ |
| Import notification | ✅ | ✅ | | Import notification | ✅ | ✅ |
@@ -64,7 +66,7 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
--- ---
## [Unreleased] ## [2.3] — 2026-09-04
### Fixed ### Fixed
- **YT-Music-Suche war praktisch tot** — `getYtmVideoUrls` verwarf die komplette Trefferliste, sobald - **YT-Music-Suche war praktisch tot** — `getYtmVideoUrls` verwarf die komplette Trefferliste, sobald
@@ -101,16 +103,25 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
live recordings. The variant blocklist (remix/cover/acoustic/…) now includes `live`. As with the live recordings. The variant blocklist (remix/cover/acoustic/…) now includes `live`. As with the
other rules it switches itself off when the searched title says live, so "Live Forever" and other rules it switches itself off when the searched title says live, so "Live Forever" and
deliberately requested live versions are unaffected. deliberately requested live versions are unaffected.
- **Album-Position im Web wurde doch nicht wiederhergestellt** — die gespeicherte Scrollposition wurde
--- in genau einem `requestAnimationFrame` gesetzt. Solange Cover und Listeneinträge noch nachluden, war
`scrollHeight` kleiner als das Ziel, und der Browser kappte den Wert stillschweigend: man landete
## [2.3] — 2026-09-03 wieder oben. Der Wert wird jetzt über bis zu ~0,5 s erneut gesetzt, bis er sitzt.
A deployment-and-delivery release: production **pulls pre-built images** instead of compiling on the
server, the **APK download works again** (it had been broken by Gitea's own link host), and the
scroll memory from 2.2 finally behaves on the Alben tab — plus it reaches an actual build.
### Added ### Added
- **Android: Songs von der Festplatte löschen** — im Song-Kontextmenü, unten abgesetzt und rot, mit
derselben Sicherheitsabfrage wie im Web. Nutzt denselben Backend-Endpunkt (`/api/delete-song`) und
erscheint nur, wenn ein Backend konfiguriert ist — Navidrome allein kann keine Dateien entfernen.
- **Android: Playlist direkt aus dem Import anlegen** — der Importer hat jetzt wie das Web ein
Häkchen „Playlist daraus anlegen" samt Namensfeld (ab zwei Titeln). Nach dem Download stößt er
Navidromes Scan an, sucht jeden Titel und legt die Playlist aus den gefundenen an; nicht
gefundene werden gezählt statt die Erstellung scheitern zu lassen.
Zusätzlich zum Auslieferungsteil dieser Version: production **pulls pre-built images** instead of
compiling on the server, the **APK download works again** (it had been broken by Gitea's own link
host), and the scroll memory from 2.2 finally behaves on the Alben tab.
### Added (Auslieferung)
- **Pull-based production stack** — `docker-compose.yml` now references - **Pull-based production stack** — `docker-compose.yml` now references
`${REGISTRY}/novasonic-frontend|-backend|-scrobble:${TAG:-latest}` and builds nothing on the host: `${REGISTRY}/novasonic-frontend|-backend|-scrobble:${TAG:-latest}` and builds nothing on the host:
`docker compose pull && docker compose up -d`. Building locally from the Dockerfiles moved to `docker compose pull && docker compose up -d`. Building locally from the Dockerfiles moved to
@@ -8,6 +8,7 @@ import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.* import io.ktor.client.request.*
import io.ktor.http.* import io.ktor.http.*
import io.ktor.http.isSuccess
import io.ktor.serialization.kotlinx.json.* import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -54,5 +55,18 @@ class ImporterClient(private val backendUrl: String) {
suspend fun status(jobId: String): DownloadStatus = suspend fun status(jobId: String): DownloadStatus =
http.get("${base()}/api/status/$jobId").body() http.get("${base()}/api/status/$jobId").body()
/**
* Loescht einen Song endgueltig von der Platte — derselbe Endpunkt, den der Web-Client nutzt.
* Das Backend braucht die Navidrome-Zugangsdaten, weil es den Dateipfad ueber getSong aufloest.
* Wirft bei HTTP != 2xx (Ktor `expectSuccess` ist hier aus, deshalb der explizite Check).
*/
suspend fun deleteSong(ndUrl: String, user: String, pass: String, songId: String) {
val res = http.post("${base()}/api/delete-song") {
contentType(ContentType.Application.Json)
setBody(DeleteSongRequest(ndUrl.trimEnd('/'), user, pass, songId))
}
if (!res.status.isSuccess()) error("HTTP ${res.status.value}")
}
fun close() = http.close() fun close() = http.close()
} }
@@ -163,6 +163,9 @@ class SubsonicClient(
suspend fun addSongToPlaylist(playlistId: String, songId: String) = suspend fun addSongToPlaylist(playlistId: String, songId: String) =
getList("updatePlaylist.view", listOf("playlistId" to playlistId, "songIdToAdd" to songId)) getList("updatePlaylist.view", listOf("playlistId" to playlistId, "songIdToAdd" to songId))
/** Stoesst Navidromes Bibliotheks-Scan an — nach einem Import, damit frische Dateien auffindbar sind. */
suspend fun startScan() = get("startScan.view")
suspend fun createPlaylist(name: String, songIds: List<String> = emptyList()): com.novasonic.app.api.model.PlaylistWithSongs { suspend fun createPlaylist(name: String, songIds: List<String> = emptyList()): com.novasonic.app.api.model.PlaylistWithSongs {
val params = mutableListOf("name" to name) val params = mutableListOf("name" to name)
songIds.forEach { params += "songId" to it } songIds.forEach { params += "songId" to it }
@@ -42,6 +42,15 @@ data class DownloadRequest(
@Serializable @Serializable
data class ResolveRequest(val url: String) data class ResolveRequest(val url: String)
/** Body fuer POST {backend}/api/delete-song — loescht die Datei von der Platte (wie im Web). */
@Serializable
data class DeleteSongRequest(
val ndUrl: String,
val user: String,
val pass: String,
val songId: String,
)
/** Response from GET {backend}/api/status/{jobId} */ /** Response from GET {backend}/api/status/{jobId} */
@Serializable @Serializable
data class DownloadStatus( data class DownloadStatus(
@@ -106,6 +106,7 @@ class MusicRepository(private val context: Context) {
suspend fun getSongsByGenre(genre: String) = api().getSongsByGenre(genre) suspend fun getSongsByGenre(genre: String) = api().getSongsByGenre(genre)
suspend fun addSongToPlaylist(playlistId: String, songId: String) = api().addSongToPlaylist(playlistId, songId) suspend fun addSongToPlaylist(playlistId: String, songId: String) = api().addSongToPlaylist(playlistId, songId)
suspend fun createPlaylist(name: String, songIds: List<String>) = api().createPlaylist(name, songIds) suspend fun createPlaylist(name: String, songIds: List<String>) = api().createPlaylist(name, songIds)
suspend fun startScan() = api().startScan()
suspend fun updatePlaylistMeta(playlistId: String, name: String, comment: String? = null) = api().updatePlaylistMeta(playlistId, name, comment) suspend fun updatePlaylistMeta(playlistId: String, name: String, comment: String? = null) = api().updatePlaylistMeta(playlistId, name, comment)
suspend fun removeSongsFromPlaylist(playlistId: String, indices: List<Int>) = api().removeSongsFromPlaylist(playlistId, indices) suspend fun removeSongsFromPlaylist(playlistId: String, indices: List<Int>) = api().removeSongsFromPlaylist(playlistId, indices)
suspend fun deletePlaylist(playlistId: String) = api().deletePlaylist(playlistId) suspend fun deletePlaylist(playlistId: String) = api().deletePlaylist(playlistId)
@@ -128,6 +128,7 @@ fun AppNavigation() {
var contextMenuSong by remember { mutableStateOf<Song?>(null) } var contextMenuSong by remember { mutableStateOf<Song?>(null) }
var playlistPickerSong by remember { mutableStateOf<Song?>(null) } var playlistPickerSong by remember { mutableStateOf<Song?>(null) }
var infoSheetSong by remember { mutableStateOf<Song?>(null) } var infoSheetSong by remember { mutableStateOf<Song?>(null) }
var songToDelete by remember { mutableStateOf<Song?>(null) }
var showNotifications by remember { mutableStateOf(false) } var showNotifications by remember { mutableStateOf(false) }
var selectedAbsItem by remember { mutableStateOf<AbsLibraryItem?>(null) } var selectedAbsItem by remember { mutableStateOf<AbsLibraryItem?>(null) }
var showAbsPlayer by remember { mutableStateOf(false) } var showAbsPlayer by remember { mutableStateOf(false) }
@@ -582,6 +583,44 @@ fun AppNavigation() {
Toast.makeText(context, "Download gestartet: ${song.title}", android.widget.Toast.LENGTH_SHORT).show() Toast.makeText(context, "Download gestartet: ${song.title}", android.widget.Toast.LENGTH_SHORT).show()
contextMenuSong = null contextMenuSong = null
}, },
// Nur mit Backend: das Loeschen laeuft ueber /api/delete-song, Navidrome allein
// kann keine Dateien entfernen.
onDelete = if (appSettings.backendUrl.isNotBlank()) {
{ songToDelete = song; contextMenuSong = null }
} else null,
)
}
// Sicherheitsabfrage vor dem endgueltigen Loeschen — Wortlaut wie im Web.
songToDelete?.let { song ->
AlertDialog(
onDismissRequest = { songToDelete = null },
title = { Text("Song löschen") },
text = { Text("\"${song.title}\" wird unwiderruflich von der Festplatte gelöscht. " +
"Leere Album- und Künstlerordner werden ebenfalls entfernt.") },
confirmButton = {
TextButton(onClick = {
songToDelete = null
scope.launch {
val creds = repo.lastCredentials
val client = com.novasonic.app.api.ImporterClient(appSettings.backendUrl)
val res = runCatching {
client.deleteSong(creds?.serverUrl ?: "", creds?.username ?: "",
creds?.password ?: "", song.id)
}
client.close()
Toast.makeText(
context,
res.fold({ "🗑 \"${song.title}\" gelöscht" },
{ "Löschen fehlgeschlagen: ${it.message}" }),
Toast.LENGTH_LONG,
).show()
// Navidrome braucht einen Moment zum Nachscannen, dann die Ansicht neu laden.
if (res.isSuccess) { kotlinx.coroutines.delay(3000); homeRefreshKey++ }
}
}) { Text("Löschen", color = MaterialTheme.colorScheme.error) }
},
dismissButton = { TextButton(onClick = { songToDelete = null }) { Text("Abbrechen") } },
) )
} }
@@ -33,6 +33,7 @@ fun SongContextSheet(
onGoToAlbum: (() -> Unit)? = null, onGoToAlbum: (() -> Unit)? = null,
onShowInfo: (() -> Unit)? = null, onShowInfo: (() -> Unit)? = null,
onDownload: (() -> Unit)? = null, onDownload: (() -> Unit)? = null,
onDelete: (() -> Unit)? = null,
) { ) {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -79,6 +80,15 @@ fun SongContextSheet(
onDownload?.let { CtxItem(Icons.Default.Download, "Herunterladen", onClick = { onDismiss(); it() }) } onDownload?.let { CtxItem(Icons.Default.Download, "Herunterladen", onClick = { onDismiss(); it() }) }
} }
// Loeschen steht bewusst unten, abgesetzt und rot: es entfernt die Datei unwiderruflich von
// der Platte. Die Sicherheitsabfrage sitzt beim Aufrufer, wie im Web auch.
onDelete?.let {
HorizontalDivider(color = NsBorder, modifier = Modifier.padding(vertical = 4.dp))
CtxItem(Icons.Default.DeleteForever, "Von Festplatte löschen",
tint = MaterialTheme.colorScheme.error,
onClick = { onDismiss(); it() })
}
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
} }
} }
@@ -182,6 +182,38 @@ fun ImporterScreen(onBack: () -> Unit) {
} }
} }
// Playlist aus dem Import anlegen — wie im Web-Importer. Nur ab zwei Titeln sinnvoll;
// ein einzelner Track ergibt keine Playlist.
if (r.tracks.size > 1) {
item {
Column(Modifier.fillMaxWidth().padding(bottom = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = state.createPlaylist,
onCheckedChange = vm::setCreatePlaylist,
colors = CheckboxDefaults.colors(checkedColor = brand,
checkmarkColor = Color.Black),
)
Text("Playlist daraus anlegen", fontFamily = Outfit, fontSize = 14.sp,
color = NsOnSurface)
}
if (state.createPlaylist) {
OutlinedTextField(
value = state.playlistName.ifBlank { r.name ?: "" },
onValueChange = vm::setPlaylistName,
label = { Text("Playlist-Name", fontFamily = Outfit) },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
)
}
state.playlistStatus?.let {
Text(it, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
modifier = Modifier.padding(top = 6.dp))
}
}
}
}
// Download button / job progress // Download button / job progress
item { item {
if (state.isDownloading || state.jobStatus == "done" || state.jobStatus == "error") { if (state.isDownloading || state.jobStatus == "done" || state.jobStatus == "error") {
@@ -29,6 +29,11 @@ data class ImporterUiState(
val jobTotal: Int = 0, val jobTotal: Int = 0,
// 30s preview — synthetic id (previewUrl hash) of the clip currently playing, or null // 30s preview — synthetic id (previewUrl hash) of the clip currently playing, or null
val previewingId: Long? = null, val previewingId: Long? = null,
// Playlist aus dem Import anlegen (wie im Web-Importer). Nur sinnvoll ab zwei Titeln.
val createPlaylist: Boolean = true,
val playlistName: String = "",
val playlistStatus: String? = null,
) )
class ImporterViewModel(app: Application) : AndroidViewModel(app) { class ImporterViewModel(app: Application) : AndroidViewModel(app) {
@@ -69,6 +74,9 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
fun stopPreview() = preview.stop() fun stopPreview() = preview.stop()
fun setCreatePlaylist(v: Boolean) { _state.value = _state.value.copy(createPlaylist = v) }
fun setPlaylistName(v: String) { _state.value = _state.value.copy(playlistName = v) }
fun onLink(v: String) { fun onLink(v: String) {
_state.value = _state.value.copy( _state.value = _state.value.copy(
link = v, link = v,
@@ -173,6 +181,11 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
) )
if (st.status == "done" || st.status == "error") { if (st.status == "done" || st.status == "error") {
_state.value = _state.value.copy(isDownloading = false) _state.value = _state.value.copy(isDownloading = false)
val cur = _state.value
if (st.status == "done" && cur.createPlaylist && cur.resolved!!.tracks.size > 1) {
buildPlaylist(cur.playlistName.ifBlank { cur.resolved.name ?: "Import" },
cur.resolved.tracks)
}
return return
} }
} }
@@ -182,6 +195,47 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
} }
} }
/**
* Legt nach dem Import eine Navidrome-Playlist aus den importierten Titeln an — dieselbe Abfolge
* wie im Web: Scan anstossen, kurz warten, jeden Titel suchen, gefundene IDs in eine neue
* Playlist. Titel, die Navidrome (noch) nicht kennt, werden gezaehlt statt zu scheitern.
*/
private suspend fun buildPlaylist(name: String, tracks: List<ImportTrack>) {
_state.value = _state.value.copy(playlistStatus = "Navidrome scannt…")
runCatching { repo.startScan() }
delay(4000)
val norm = { s: String -> s.lowercase().replace(Regex("[^\\p{L}\\p{N}]+"), "") }
val ids = mutableListOf<String>()
var fehlend = 0
for (t in tracks) {
val titel = t.trackName ?: continue
val treffer = runCatching {
val q = listOfNotNull(t.artistName, titel).joinToString(" ")
val gefunden = repo.search(q).song
gefunden.firstOrNull { norm(it.title) == norm(titel) }
?: repo.search(titel).song.firstOrNull { norm(it.title) == norm(titel) }
}.getOrNull()
if (treffer != null) ids += treffer.id else fehlend++
_state.value = _state.value.copy(
playlistStatus = "Playlist: ${ids.size}/${tracks.size} gefunden…")
}
if (ids.isEmpty()) {
_state.value = _state.value.copy(
playlistStatus = "Keine Titel in Navidrome gefunden — Playlist nicht angelegt.")
return
}
val res = runCatching { repo.createPlaylist(name, ids) }
_state.value = _state.value.copy(
playlistStatus = res.fold(
{ "Playlist \"$name\" angelegt: ${ids.size}/${tracks.size} Titel" +
if (fehlend > 0) " ($fehlend nicht gefunden)" else "" },
{ "Playlist fehlgeschlagen: ${it.message}" },
),
)
}
private fun mapError(code: String): String = when (code) { private fun mapError(code: String): String = when (code) {
"EDITORIAL_PLAYLIST" -> "EDITORIAL_PLAYLIST" ->
"Editorial Playlist: Spotify sperrt eigene kuratierte Playlists. Kopiere sie in eine eigene Playlist und importiere diese." "Editorial Playlist: Spotify sperrt eigene kuratierte Playlists. Kopiere sie in eine eigene Playlist und importiere diese."
+829
View File
@@ -0,0 +1,829 @@
{
"name": "novasonic-scrobble",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "novasonic-scrobble",
"dependencies": {
"express": "^4.22.2"
},
"engines": {
"node": ">=22"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
}
}
}
+4 -2
View File
@@ -2,11 +2,13 @@
"name": "novasonic-scrobble", "name": "novasonic-scrobble",
"private": true, "private": true,
"description": "Isolated timestamped play-log service for NovaSonic (stats + Wrapped).", "description": "Isolated timestamped play-log service for NovaSonic (stats + Wrapped).",
"engines": { "node": ">=22" }, "engines": {
"node": ">=22"
},
"scripts": { "scripts": {
"start": "node --experimental-sqlite scrobble-server.js" "start": "node --experimental-sqlite scrobble-server.js"
}, },
"dependencies": { "dependencies": {
"express": "^4.18.0" "express": "^4.22.2"
} }
} }
+16 -1
View File
@@ -59,6 +59,21 @@ export function refreshCurrentView() {
} }
} }
// Gespeicherte Scrollposition wiederherstellen. Ein einzelner requestAnimationFrame reicht dafuer
// nicht: solange Cover und Listeneintraege noch nachladen, ist scrollHeight kleiner als das Ziel und
// der Browser kappt den gesetzten Wert stillschweigend — man landete dann doch wieder oben. Deshalb
// wird der Wert ueber bis zu ~0,5 s erneut gesetzt, bis er sitzt.
function _restoreScroll(el, y) {
if(!el) return;
if(!y) { el.scrollTop = 0; return; }
let versuche = 0;
const setzen = () => {
el.scrollTop = y;
if(Math.abs(el.scrollTop - y) > 2 && versuche++ < 30) requestAnimationFrame(setzen);
};
requestAnimationFrame(setzen);
}
export function navigate(page, data=null) { export function navigate(page, data=null) {
// Scroll + search memory: remember where each history entry was scrolled AND any open local // Scroll + search memory: remember where each history entry was scrolled AND any open local
// (header) search, so Back/Forward returns you to the same spot with the same query. The global // (header) search, so Back/Forward returns you to the same spot with the same query. The global
@@ -86,7 +101,7 @@ export function navigate(page, data=null) {
// Back/Forward → restore this entry's saved scroll (rAF so the shown page has laid out) and // Back/Forward → restore this entry's saved scroll (rAF so the shown page has laid out) and
// re-open the local (header) search if one was active here. // re-open the local (header) search if one was active here.
const y = S._scrollByIdx[S._navCursor] || 0; const y = S._scrollByIdx[S._navCursor] || 0;
if(_main) requestAnimationFrame(() => { _main.scrollTop = y; }); if(_main) _restoreScroll(_main, y);
const hsq = S._hsByIdx[S._navCursor]; const hsq = S._hsByIdx[S._navCursor];
if(hsq && _hsInput) { _hsInput.value = hsq; onHeaderSearch(hsq); } if(hsq && _hsInput) { _hsInput.value = hsq; onHeaderSearch(hsq); }
} else if(_main) { } else if(_main) {