mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-17 19:24:22 +02:00
add stats tracking + stats page + fix scrolling bug in dynamic playlists tray
This commit is contained in:
+1022
File diff suppressed because it is too large
Load Diff
+176
@@ -565,6 +565,182 @@ class RoomLibraryRepositoryTest {
|
||||
assertEquals(CURRENT_ARTIST_CREDIT_VERSION, dao.getSource("local:1")?.artistCreditVersion)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listeningStatsCheckpointIsIdempotentAndClearPreservesProtectedHistory() = runBlocking {
|
||||
val path = "content://track/listening.flac"
|
||||
publish("listening", listOf(track("listening", 1, "Headphones On", path)))
|
||||
val status = ListeningStatsEngine.status(user)
|
||||
val generation = status.getValue("generation") as String
|
||||
val startedAt = System.currentTimeMillis() - 20_000
|
||||
val common = mapOf<String, Any?>(
|
||||
"generation" to generation,
|
||||
"sessionKey" to "session-1",
|
||||
"segmentKey" to "segment-1",
|
||||
"trackPath" to path,
|
||||
"sessionStartedAt" to startedAt,
|
||||
"segmentStartedAt" to startedAt,
|
||||
"trackDurationSeconds" to 181.0,
|
||||
"qualificationEligible" to true,
|
||||
"completedNaturally" to false,
|
||||
"finalizeSegment" to false,
|
||||
"finalizeSession" to false,
|
||||
)
|
||||
|
||||
ListeningStatsEngine.checkpoint(
|
||||
user,
|
||||
catalog.catalogDao(),
|
||||
common + mapOf(
|
||||
"observedAt" to startedAt + 10_000,
|
||||
"sessionListenedSeconds" to 10.0,
|
||||
"segmentListenedSeconds" to 10.0,
|
||||
),
|
||||
)
|
||||
val qualified = ListeningStatsEngine.checkpoint(
|
||||
user,
|
||||
catalog.catalogDao(),
|
||||
common + mapOf(
|
||||
"observedAt" to startedAt + 15_000,
|
||||
"sessionListenedSeconds" to 15.0,
|
||||
"segmentListenedSeconds" to 15.0,
|
||||
),
|
||||
)
|
||||
val duplicate = ListeningStatsEngine.checkpoint(
|
||||
user,
|
||||
catalog.catalogDao(),
|
||||
common + mapOf(
|
||||
"observedAt" to startedAt + 15_000,
|
||||
"sessionListenedSeconds" to 15.0,
|
||||
"segmentListenedSeconds" to 15.0,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(true, qualified["qualifiedNow"])
|
||||
assertEquals(false, duplicate["qualifiedNow"])
|
||||
assertEquals(1L, user.userDao().getPlaybackHistory(path)?.playCount)
|
||||
|
||||
val dashboard = ListeningStatsEngine.dashboard(
|
||||
user,
|
||||
catalog.catalogDao(),
|
||||
mapOf(
|
||||
"range" to "all",
|
||||
"rankingMetric" to "plays",
|
||||
"artistGroupingMode" to "astra",
|
||||
"now" to startedAt + 20_000,
|
||||
),
|
||||
)
|
||||
val summary = dashboard.getValue("summary") as Map<*, *>
|
||||
assertEquals(15.0, (summary["listenedSeconds"] as Number).toDouble(), 0.001)
|
||||
assertEquals(1.0, (summary["qualifiedPlays"] as Number).toDouble(), 0.001)
|
||||
assertEquals(1.0, (summary["tracksPlayed"] as Number).toDouble(), 0.001)
|
||||
|
||||
val cleared = ListeningStatsEngine.clear(user)
|
||||
assertNull(cleared["startedAt"])
|
||||
assertEquals(1L, user.userDao().getPlaybackHistory(path)?.playCount)
|
||||
assertEquals(0, user.userDao().getListeningSessionsInRange(generation, 0, Long.MAX_VALUE).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listeningStatsExcludePausedGapAndRetainRemovedTrackMetadata() = runBlocking {
|
||||
val path = "content://track/removed.flac"
|
||||
publish("stats-old", listOf(track("stats-old", 2, "Song That Left", path)))
|
||||
val generation = ListeningStatsEngine.status(user).getValue("generation") as String
|
||||
val startedAt = System.currentTimeMillis() - 60_000
|
||||
val base = mapOf<String, Any?>(
|
||||
"generation" to generation,
|
||||
"sessionKey" to "session-split",
|
||||
"trackPath" to path,
|
||||
"sessionStartedAt" to startedAt,
|
||||
"trackDurationSeconds" to 182.0,
|
||||
"qualificationEligible" to true,
|
||||
"completedNaturally" to false,
|
||||
)
|
||||
ListeningStatsEngine.checkpoint(
|
||||
user,
|
||||
catalog.catalogDao(),
|
||||
base + mapOf(
|
||||
"segmentKey" to "segment-a",
|
||||
"segmentStartedAt" to startedAt,
|
||||
"observedAt" to startedAt + 5_000,
|
||||
"sessionListenedSeconds" to 5.0,
|
||||
"segmentListenedSeconds" to 5.0,
|
||||
"finalizeSegment" to true,
|
||||
"finalizeSession" to false,
|
||||
),
|
||||
)
|
||||
ListeningStatsEngine.checkpoint(
|
||||
user,
|
||||
catalog.catalogDao(),
|
||||
base + mapOf(
|
||||
"segmentKey" to "segment-b",
|
||||
"segmentStartedAt" to startedAt + 35_000,
|
||||
"observedAt" to startedAt + 45_000,
|
||||
"sessionListenedSeconds" to 15.0,
|
||||
"segmentListenedSeconds" to 10.0,
|
||||
"finalizeSegment" to true,
|
||||
"finalizeSession" to true,
|
||||
),
|
||||
)
|
||||
|
||||
publish(
|
||||
"stats-new",
|
||||
listOf(track("stats-new", 3, "A Different Song")),
|
||||
previous = "stats-old",
|
||||
)
|
||||
val dashboard = ListeningStatsEngine.dashboard(
|
||||
user,
|
||||
catalog.catalogDao(),
|
||||
mapOf(
|
||||
"range" to "all",
|
||||
"rankingMetric" to "time",
|
||||
"artistGroupingMode" to "fileTags",
|
||||
"now" to startedAt + 55_000,
|
||||
),
|
||||
)
|
||||
val summary = dashboard.getValue("summary") as Map<*, *>
|
||||
assertEquals(15.0, (summary["listenedSeconds"] as Number).toDouble(), 0.001)
|
||||
val topTrack = (dashboard.getValue("topTracks") as List<*>).single() as Map<*, *>
|
||||
assertEquals("Song That Left", topTrack["title"])
|
||||
assertEquals(false, topTrack["available"])
|
||||
assertNull(topTrack["trackPath"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listeningStatsShortTracksQualifyOnlyOnNaturalCompletion() = runBlocking {
|
||||
val path = "content://track/short.flac"
|
||||
publish(
|
||||
"stats-short",
|
||||
listOf(track("stats-short", 4, "Short Song", path).copy(duration = 5.0)),
|
||||
)
|
||||
val generation = ListeningStatsEngine.status(user).getValue("generation") as String
|
||||
val startedAt = System.currentTimeMillis() - 10_000
|
||||
suspend fun checkpoint(sessionKey: String, completedNaturally: Boolean): Map<String, Any?> =
|
||||
ListeningStatsEngine.checkpoint(
|
||||
user,
|
||||
catalog.catalogDao(),
|
||||
mapOf(
|
||||
"generation" to generation,
|
||||
"sessionKey" to sessionKey,
|
||||
"segmentKey" to "segment-$sessionKey",
|
||||
"trackPath" to path,
|
||||
"sessionStartedAt" to startedAt,
|
||||
"segmentStartedAt" to startedAt,
|
||||
"observedAt" to startedAt + 4_500,
|
||||
"sessionListenedSeconds" to 4.5,
|
||||
"segmentListenedSeconds" to 4.5,
|
||||
"trackDurationSeconds" to 5.0,
|
||||
"qualificationEligible" to true,
|
||||
"completedNaturally" to completedNaturally,
|
||||
"finalizeSegment" to true,
|
||||
"finalizeSession" to true,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(false, checkpoint("manual", false)["qualifiedNow"])
|
||||
assertNull(user.userDao().getPlaybackHistory(path))
|
||||
assertEquals(true, checkpoint("natural", true)["qualifiedNow"])
|
||||
assertEquals(1L, user.userDao().getPlaybackHistory(path)?.playCount)
|
||||
}
|
||||
|
||||
/** 10 tracks under each of A-Z, so every section has rows above and below it. */
|
||||
private fun seedAlphabet(): List<TrackEntity> =
|
||||
(0 until ALPHABET_SEED_SIZE).map { index ->
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package expo.modules.astralibraryscanner.data
|
||||
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class UserMigrationTest {
|
||||
@get:Rule
|
||||
val helper = MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
AstraUserDatabase::class.java,
|
||||
)
|
||||
|
||||
@After
|
||||
fun cleanUp() {
|
||||
InstrumentationRegistry.getInstrumentation().targetContext.deleteDatabase(TEST_DATABASE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun migrationAddsDetailedHistoryWithoutChangingProtectedUserData() {
|
||||
helper.createDatabase(TEST_DATABASE, 1).apply {
|
||||
execSQL("INSERT INTO settings (`key`, value) VALUES ('theme_base', 'amoled')")
|
||||
execSQL(
|
||||
"""
|
||||
INSERT INTO folders
|
||||
(id, tree_uri, display_name, added_at, last_scanned_at, last_scan_status, last_scan_error)
|
||||
VALUES (1, 'content://music', 'Music', 10, 20, 'ready', NULL)
|
||||
""".trimIndent(),
|
||||
)
|
||||
execSQL(
|
||||
"""
|
||||
INSERT INTO playlists
|
||||
(id, name, created_at, updated_at, last_played_at, kind, dynamic_rules_json,
|
||||
remote_source_id, remote_playlist_id, sync_uid)
|
||||
VALUES (1, 'Keep Me', 10, 20, 30, 'static', NULL, NULL, NULL, 'playlist-1')
|
||||
""".trimIndent(),
|
||||
)
|
||||
execSQL(
|
||||
"""
|
||||
INSERT INTO playlist_tracks
|
||||
(id, playlist_id, track_path, position, added_at, fallback_title,
|
||||
fallback_artist, fallback_album)
|
||||
VALUES (1, 1, '/music/track.flac', 0, 10, 'Track', 'Artist', 'Album')
|
||||
""".trimIndent(),
|
||||
)
|
||||
execSQL("INSERT INTO favorites (track_path, added_at) VALUES ('/music/track.flac', 10)")
|
||||
execSQL(
|
||||
"""
|
||||
INSERT INTO playback_history (track_path, last_played_at, play_count)
|
||||
VALUES ('/music/track.flac', 30, 9)
|
||||
""".trimIndent(),
|
||||
)
|
||||
execSQL(
|
||||
"""
|
||||
INSERT INTO playback_sessions
|
||||
(id, context_json, anchor_path, shuffle_seed, active_position, created_at, updated_at)
|
||||
VALUES ('session', '{}', '/music/track.flac', NULL, 0, 10, 20)
|
||||
""".trimIndent(),
|
||||
)
|
||||
execSQL(
|
||||
"""
|
||||
INSERT INTO playback_queue_entries (session_id, position, track_path)
|
||||
VALUES ('session', 0, '/music/track.flac')
|
||||
""".trimIndent(),
|
||||
)
|
||||
close()
|
||||
}
|
||||
|
||||
val database = helper.runMigrationsAndValidate(
|
||||
TEST_DATABASE,
|
||||
2,
|
||||
true,
|
||||
USER_MIGRATION_1_2,
|
||||
)
|
||||
|
||||
assertEquals("amoled", database.singleString("SELECT value FROM settings WHERE `key` = 'theme_base'"))
|
||||
assertEquals("Music", database.singleString("SELECT display_name FROM folders WHERE id = 1"))
|
||||
assertEquals("Keep Me", database.singleString("SELECT name FROM playlists WHERE id = 1"))
|
||||
assertEquals(1, database.singleInt("SELECT COUNT(*) FROM playlist_tracks"))
|
||||
assertEquals(1, database.singleInt("SELECT COUNT(*) FROM favorites"))
|
||||
assertEquals(9, database.singleInt("SELECT play_count FROM playback_history"))
|
||||
assertEquals(1, database.singleInt("SELECT COUNT(*) FROM playback_sessions"))
|
||||
assertEquals(1, database.singleInt("SELECT COUNT(*) FROM playback_queue_entries"))
|
||||
|
||||
val tables = buildSet {
|
||||
database.query(
|
||||
"""
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type = 'table' AND name LIKE 'listening_%'
|
||||
""".trimIndent(),
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) add(cursor.getString(0))
|
||||
}
|
||||
}
|
||||
assertTrue("listening_history_meta" in tables)
|
||||
assertTrue("listening_sessions" in tables)
|
||||
assertTrue("listening_segments" in tables)
|
||||
}
|
||||
|
||||
private fun androidx.sqlite.db.SupportSQLiteDatabase.singleString(query: String): String =
|
||||
this.query(query).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
cursor.getString(0)
|
||||
}
|
||||
|
||||
private fun androidx.sqlite.db.SupportSQLiteDatabase.singleInt(query: String): Int =
|
||||
this.query(query).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
cursor.getInt(0)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TEST_DATABASE = "listening-history-user-migration-test"
|
||||
}
|
||||
}
|
||||
+16
@@ -204,6 +204,22 @@ class AstraLibraryDataModule : Module() {
|
||||
repositoryCall { recordTrackPlayed(path) }
|
||||
}
|
||||
|
||||
AsyncFunction("getListeningHistoryStatus").Coroutine<Map<String, Any?>> {
|
||||
repositoryCall { getListeningHistoryStatus() }
|
||||
}
|
||||
|
||||
AsyncFunction("checkpointListeningSession") Coroutine { payload: Map<String, Any?> ->
|
||||
repositoryCall { checkpointListeningSession(payload) }
|
||||
}
|
||||
|
||||
AsyncFunction("getListeningStatsDashboard") Coroutine { query: Map<String, Any?> ->
|
||||
repositoryCall { getListeningStatsDashboard(query) }
|
||||
}
|
||||
|
||||
AsyncFunction("clearDetailedListeningHistory").Coroutine<Map<String, Any?>> {
|
||||
repositoryCall { clearDetailedListeningHistory() }
|
||||
}
|
||||
|
||||
AsyncFunction("getRecentlyPlayed") Coroutine { limit: Int ->
|
||||
repositoryCall { getRecentlyPlayed(limit) }
|
||||
}
|
||||
|
||||
+31
@@ -1194,6 +1194,36 @@ class AstraLibraryRepository private constructor(
|
||||
return true
|
||||
}
|
||||
|
||||
suspend fun getListeningHistoryStatus(): Map<String, Any?> {
|
||||
initialize()
|
||||
return ListeningStatsEngine.status(requireUser())
|
||||
}
|
||||
|
||||
suspend fun checkpointListeningSession(payload: Map<String, Any?>): Map<String, Any?> {
|
||||
initialize()
|
||||
val result = ListeningStatsEngine.checkpoint(
|
||||
requireUser(),
|
||||
requireCatalog().catalogDao(),
|
||||
payload,
|
||||
)
|
||||
if (result["qualifiedNow"] == true) scheduleSnapshot()
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun getListeningStatsDashboard(query: Map<String, Any?>): Map<String, Any?> {
|
||||
initialize()
|
||||
return ListeningStatsEngine.dashboard(
|
||||
requireUser(),
|
||||
requireCatalog().catalogDao(),
|
||||
query,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun clearDetailedListeningHistory(): Map<String, Any?> {
|
||||
initialize()
|
||||
return ListeningStatsEngine.clear(requireUser())
|
||||
}
|
||||
|
||||
suspend fun listRemoteSources(): List<Map<String, Any?>> {
|
||||
initialize()
|
||||
return requireUser().userDao().getRemoteSources().map(RemoteSourceEntity::toBridgeMap)
|
||||
@@ -2762,6 +2792,7 @@ class AstraLibraryRepository private constructor(
|
||||
private fun buildUserDatabase(): AstraUserDatabase =
|
||||
Room.databaseBuilder(applicationContext, AstraUserDatabase::class.java, USER_DB_NAME)
|
||||
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
|
||||
.addMigrations(USER_MIGRATION_1_2)
|
||||
.build()
|
||||
|
||||
private fun buildCatalogDatabase(): AstraCatalogDatabase =
|
||||
|
||||
+785
@@ -0,0 +1,785 @@
|
||||
package expo.modules.astralibraryscanner.data
|
||||
|
||||
import androidx.room.withTransaction
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
internal const val LISTENING_HISTORY_ENABLED_KEY = "listening_history_enabled"
|
||||
private const val LISTENING_QUALIFICATION_SECONDS = 15.0
|
||||
private const val SHORT_TRACK_COMPLETION_TOLERANCE_SECONDS = 0.5
|
||||
private const val SHORT_TRACK_COMPLETION_TOLERANCE_RATIO = 0.1
|
||||
private const val TOP_LIMIT = 10
|
||||
private const val CATALOG_BATCH_SIZE = 400
|
||||
|
||||
private data class ListeningCheckpointResult(
|
||||
val accepted: Boolean,
|
||||
val qualifiedNow: Boolean,
|
||||
val history: PlaybackHistoryEntity? = null,
|
||||
)
|
||||
|
||||
private data class ActivityBucket(
|
||||
val startAt: Long,
|
||||
val endAt: Long,
|
||||
val label: String,
|
||||
var listenedSeconds: Double = 0.0,
|
||||
var qualifiedPlays: Long = 0,
|
||||
)
|
||||
|
||||
private data class ListeningIdentity(
|
||||
val trackKey: String,
|
||||
val trackPath: String?,
|
||||
val title: String,
|
||||
val artist: String,
|
||||
val artistNamesJson: String?,
|
||||
val album: String,
|
||||
val albumArtist: String?,
|
||||
val albumArtistNamesJson: String?,
|
||||
val albumKey: String,
|
||||
val artworkHash: String?,
|
||||
val sourceType: String,
|
||||
val sourceId: Long?,
|
||||
val artworkSourceId: String?,
|
||||
val available: Boolean,
|
||||
)
|
||||
|
||||
private data class TrackAggregate(
|
||||
val key: String,
|
||||
var trackPath: String?,
|
||||
var title: String,
|
||||
var artist: String,
|
||||
var album: String,
|
||||
var artworkHash: String?,
|
||||
var sourceType: String,
|
||||
var sourceId: Long?,
|
||||
var artworkSourceId: String?,
|
||||
var available: Boolean,
|
||||
var listenedSeconds: Double = 0.0,
|
||||
var qualifiedPlays: Long = 0,
|
||||
)
|
||||
|
||||
private data class ArtistAggregate(
|
||||
val key: String,
|
||||
var artist: String,
|
||||
var artworkHash: String?,
|
||||
var sourceType: String,
|
||||
var sourceId: Long?,
|
||||
var artworkSourceId: String?,
|
||||
var available: Boolean,
|
||||
var listenedSeconds: Double = 0.0,
|
||||
var qualifiedPlays: Long = 0,
|
||||
)
|
||||
|
||||
private data class AlbumAggregate(
|
||||
val key: String,
|
||||
var album: String,
|
||||
var artist: String,
|
||||
var artworkHash: String?,
|
||||
var sourceType: String,
|
||||
var sourceId: Long?,
|
||||
var artworkSourceId: String?,
|
||||
var available: Boolean,
|
||||
var listenedSeconds: Double = 0.0,
|
||||
var qualifiedPlays: Long = 0,
|
||||
)
|
||||
|
||||
internal object ListeningStatsEngine {
|
||||
suspend fun status(database: AstraUserDatabase): Map<String, Any?> {
|
||||
val dao = database.userDao()
|
||||
val meta = ensureMeta(dao)
|
||||
return statusMap(meta, listeningEnabled(dao))
|
||||
}
|
||||
|
||||
suspend fun checkpoint(
|
||||
database: AstraUserDatabase,
|
||||
catalogDao: CatalogDao,
|
||||
payload: Map<String, Any?>,
|
||||
): Map<String, Any?> {
|
||||
val dao = database.userDao()
|
||||
val meta = ensureMeta(dao)
|
||||
if (!listeningEnabled(dao)) {
|
||||
return checkpointMap(false, false, meta, false)
|
||||
}
|
||||
|
||||
val generation = payload.string("generation")
|
||||
val sessionKey = payload.string("sessionKey")
|
||||
val segmentKey = payload.string("segmentKey")
|
||||
val trackPath = payload.string("trackPath")
|
||||
if (
|
||||
generation.isEmpty() ||
|
||||
generation != meta.generation ||
|
||||
sessionKey.isEmpty() ||
|
||||
(segmentKey.isEmpty() && !payload.boolean("finalizeSession")) ||
|
||||
trackPath.isEmpty()
|
||||
) {
|
||||
return checkpointMap(false, false, meta, true)
|
||||
}
|
||||
|
||||
val track = catalogDao.getActiveTrack(trackPath)
|
||||
?: return checkpointMap(false, false, meta, true)
|
||||
val observedAt = payload.long("observedAt", System.currentTimeMillis()).coerceAtLeast(0)
|
||||
val sessionStartedAt = payload.long("sessionStartedAt", observedAt).coerceIn(0, observedAt)
|
||||
val segmentStartedAt = payload.long("segmentStartedAt", observedAt).coerceIn(0, observedAt)
|
||||
val sessionListenedSeconds = payload.double("sessionListenedSeconds").finiteNonNegative()
|
||||
val segmentListenedSeconds = min(
|
||||
sessionListenedSeconds,
|
||||
payload.double("segmentListenedSeconds").finiteNonNegative(),
|
||||
)
|
||||
val durationSeconds = max(
|
||||
track.duration.finiteNonNegative(),
|
||||
payload.double("trackDurationSeconds").finiteNonNegative(),
|
||||
)
|
||||
val finalizeSegment = payload.boolean("finalizeSegment")
|
||||
val finalizeSession = payload.boolean("finalizeSession")
|
||||
val completedNaturally = payload.boolean("completedNaturally")
|
||||
val qualificationEligible = payload["qualificationEligible"] != false
|
||||
|
||||
val result = database.withTransaction {
|
||||
val currentMeta = ensureMeta(dao)
|
||||
if (currentMeta.generation != generation || !listeningEnabled(dao)) {
|
||||
return@withTransaction ListeningCheckpointResult(false, false)
|
||||
}
|
||||
|
||||
if (sessionListenedSeconds > 0 && currentMeta.startedAt == null) {
|
||||
dao.putListeningHistoryMeta(currentMeta.copy(startedAt = segmentStartedAt))
|
||||
}
|
||||
|
||||
val existingSession = dao.getListeningSession(sessionKey)
|
||||
val session = if (existingSession == null) {
|
||||
ListeningSessionEntity(
|
||||
sessionKey = sessionKey,
|
||||
generation = generation,
|
||||
trackPath = track.path,
|
||||
title = track.title,
|
||||
artist = track.artist,
|
||||
artistNamesJson = track.artistNamesJson,
|
||||
album = track.album,
|
||||
albumArtist = track.albumArtist,
|
||||
albumArtistNamesJson = track.albumArtistNamesJson,
|
||||
albumIdentityKey = track.albumIdentityKey,
|
||||
artworkHash = track.artworkHash,
|
||||
sourceType = track.sourceType,
|
||||
sourceId = track.sourceId,
|
||||
artworkSourceId = track.artworkSourceId,
|
||||
durationSeconds = durationSeconds,
|
||||
startedAt = sessionStartedAt,
|
||||
endedAt = observedAt.takeIf { finalizeSession },
|
||||
listenedSeconds = sessionListenedSeconds,
|
||||
)
|
||||
} else {
|
||||
existingSession.copy(
|
||||
durationSeconds = max(existingSession.durationSeconds, durationSeconds),
|
||||
endedAt = maxNullable(existingSession.endedAt, observedAt.takeIf { finalizeSession }),
|
||||
listenedSeconds = max(existingSession.listenedSeconds, sessionListenedSeconds),
|
||||
)
|
||||
}
|
||||
dao.putListeningSession(session)
|
||||
|
||||
if (segmentKey.isNotEmpty()) {
|
||||
val existingSegment = dao.getListeningSegment(sessionKey, segmentKey)
|
||||
val segment = if (existingSegment == null) {
|
||||
ListeningSegmentEntity(
|
||||
sessionKey = sessionKey,
|
||||
segmentKey = segmentKey,
|
||||
generation = generation,
|
||||
startedAt = segmentStartedAt,
|
||||
lastObservedAt = observedAt,
|
||||
endedAt = observedAt.takeIf { finalizeSegment || finalizeSession },
|
||||
listenedSeconds = segmentListenedSeconds,
|
||||
)
|
||||
} else {
|
||||
existingSegment.copy(
|
||||
lastObservedAt = max(existingSegment.lastObservedAt, observedAt),
|
||||
endedAt = maxNullable(
|
||||
existingSegment.endedAt,
|
||||
observedAt.takeIf { finalizeSegment || finalizeSession },
|
||||
),
|
||||
listenedSeconds = max(existingSegment.listenedSeconds, segmentListenedSeconds),
|
||||
)
|
||||
}
|
||||
dao.putListeningSegment(segment)
|
||||
}
|
||||
|
||||
val persisted = dao.getListeningSession(sessionKey) ?: session
|
||||
val qualifies = qualificationEligible &&
|
||||
persisted.qualifiedAt == null &&
|
||||
sessionQualifies(
|
||||
listenedSeconds = persisted.listenedSeconds,
|
||||
durationSeconds = persisted.durationSeconds,
|
||||
finalizeSession = finalizeSession,
|
||||
completedNaturally = completedNaturally,
|
||||
)
|
||||
if (!qualifies || dao.qualifyListeningSession(sessionKey, generation, observedAt) == 0) {
|
||||
return@withTransaction ListeningCheckpointResult(true, false)
|
||||
}
|
||||
|
||||
val previousHistory = dao.getPlaybackHistory(track.path)
|
||||
val history = PlaybackHistoryEntity(
|
||||
trackPath = track.path,
|
||||
lastPlayedAt = observedAt,
|
||||
playCount = (previousHistory?.playCount ?: 0) + 1,
|
||||
)
|
||||
dao.putPlaybackHistory(history)
|
||||
ListeningCheckpointResult(true, true, history)
|
||||
}
|
||||
|
||||
result.history?.let { history ->
|
||||
runCatching {
|
||||
catalogDao.putTrackUserFacts(
|
||||
listOf(
|
||||
TrackUserFactEntity(
|
||||
path = track.path,
|
||||
isFavorite = dao.isFavorite(track.path),
|
||||
playCount = history.playCount,
|
||||
lastPlayedAt = history.lastPlayedAt,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
return checkpointMap(
|
||||
result.accepted,
|
||||
result.qualifiedNow,
|
||||
ensureMeta(dao),
|
||||
listeningEnabled(dao),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun clear(database: AstraUserDatabase): Map<String, Any?> {
|
||||
val dao = database.userDao()
|
||||
val meta = database.withTransaction {
|
||||
dao.clearListeningSegments()
|
||||
dao.clearListeningSessions()
|
||||
dao.clearListeningHistoryMeta()
|
||||
ListeningHistoryMetaEntity(generation = UUID.randomUUID().toString()).also {
|
||||
dao.putListeningHistoryMeta(it)
|
||||
}
|
||||
}
|
||||
return statusMap(meta, listeningEnabled(dao))
|
||||
}
|
||||
|
||||
suspend fun dashboard(
|
||||
database: AstraUserDatabase,
|
||||
catalogDao: CatalogDao,
|
||||
query: Map<String, Any?>,
|
||||
): Map<String, Any?> {
|
||||
val dao = database.userDao()
|
||||
val meta = ensureMeta(dao)
|
||||
val enabled = listeningEnabled(dao)
|
||||
val range = when (query.string("range")) {
|
||||
"7d", "1y", "all" -> query.string("range")
|
||||
else -> "30d"
|
||||
}
|
||||
val rankingMetric = if (query.string("rankingMetric") == "time") "time" else "plays"
|
||||
val groupingMode = if (query.string("artistGroupingMode") == "fileTags") "fileTags" else "astra"
|
||||
val now = query.long("now", System.currentTimeMillis()).coerceAtLeast(0)
|
||||
val rangeStartAt = rangeStart(range, now, meta.startedAt)
|
||||
val (granularity, buckets) = buildBuckets(range, rangeStartAt, now)
|
||||
if (rangeStartAt == null) {
|
||||
return emptyDashboard(meta, enabled, range, rankingMetric, now, granularity, buckets)
|
||||
}
|
||||
|
||||
val sessions = dao.getListeningSessionsInRange(meta.generation, rangeStartAt, now)
|
||||
if (sessions.isEmpty()) {
|
||||
return emptyDashboard(meta, enabled, range, rankingMetric, now, granularity, buckets)
|
||||
}
|
||||
val segments = dao.getListeningSegmentsInRange(meta.generation, rangeStartAt, now)
|
||||
val activeTracks = sessions
|
||||
.map(ListeningSessionEntity::trackPath)
|
||||
.distinct()
|
||||
.chunked(CATALOG_BATCH_SIZE)
|
||||
.flatMap { paths -> catalogDao.getActiveTracks(paths) }
|
||||
.associateBy(ActiveTrackView::path)
|
||||
val sessionsByKey = sessions.associateBy(ListeningSessionEntity::sessionKey)
|
||||
|
||||
val trackAggregates = linkedMapOf<String, TrackAggregate>()
|
||||
val artistAggregates = linkedMapOf<String, ArtistAggregate>()
|
||||
val albumAggregates = linkedMapOf<String, AlbumAggregate>()
|
||||
val tracksPlayed = linkedSetOf<String>()
|
||||
val activeDays = linkedSetOf<Long>()
|
||||
var listenedSeconds = 0.0
|
||||
var qualifiedPlays = 0L
|
||||
|
||||
fun ensureAggregates(identity: ListeningIdentity): Triple<TrackAggregate, List<ArtistAggregate>, AlbumAggregate> {
|
||||
val track = trackAggregates.getOrPut(identity.trackKey) {
|
||||
TrackAggregate(
|
||||
key = identity.trackKey,
|
||||
trackPath = identity.trackPath,
|
||||
title = identity.title,
|
||||
artist = identity.artist,
|
||||
album = identity.album,
|
||||
artworkHash = identity.artworkHash,
|
||||
sourceType = identity.sourceType,
|
||||
sourceId = identity.sourceId,
|
||||
artworkSourceId = identity.artworkSourceId,
|
||||
available = identity.available,
|
||||
)
|
||||
}.also {
|
||||
it.available = it.available || identity.available
|
||||
if (identity.available) {
|
||||
it.trackPath = identity.trackPath
|
||||
it.artworkHash = identity.artworkHash ?: it.artworkHash
|
||||
it.sourceType = identity.sourceType
|
||||
it.sourceId = identity.sourceId
|
||||
it.artworkSourceId = identity.artworkSourceId
|
||||
}
|
||||
}
|
||||
val artists = browseArtists(identity, groupingMode).map { display ->
|
||||
val key = normalizeKey(display).ifEmpty { display }
|
||||
artistAggregates.getOrPut(key) {
|
||||
ArtistAggregate(
|
||||
key = key,
|
||||
artist = display,
|
||||
artworkHash = identity.artworkHash,
|
||||
sourceType = identity.sourceType,
|
||||
sourceId = identity.sourceId,
|
||||
artworkSourceId = identity.artworkSourceId,
|
||||
available = identity.available,
|
||||
)
|
||||
}.also {
|
||||
it.available = it.available || identity.available
|
||||
it.artworkHash = it.artworkHash ?: identity.artworkHash
|
||||
it.sourceId = it.sourceId ?: identity.sourceId
|
||||
it.artworkSourceId = it.artworkSourceId ?: identity.artworkSourceId
|
||||
}
|
||||
}
|
||||
val album = albumAggregates.getOrPut(identity.albumKey) {
|
||||
AlbumAggregate(
|
||||
key = identity.albumKey,
|
||||
album = identity.album,
|
||||
artist = identity.albumArtist?.takeIf(String::isNotBlank) ?: identity.artist,
|
||||
artworkHash = identity.artworkHash,
|
||||
sourceType = identity.sourceType,
|
||||
sourceId = identity.sourceId,
|
||||
artworkSourceId = identity.artworkSourceId,
|
||||
available = identity.available,
|
||||
)
|
||||
}.also {
|
||||
it.available = it.available || identity.available
|
||||
it.artworkHash = it.artworkHash ?: identity.artworkHash
|
||||
it.sourceId = it.sourceId ?: identity.sourceId
|
||||
it.artworkSourceId = it.artworkSourceId ?: identity.artworkSourceId
|
||||
}
|
||||
return Triple(track, artists, album)
|
||||
}
|
||||
|
||||
for (segment in segments) {
|
||||
val session = sessionsByKey[segment.sessionKey] ?: continue
|
||||
val overlap = overlapSeconds(segment, rangeStartAt, now + 1)
|
||||
if (overlap <= 0) continue
|
||||
val identity = identity(session, activeTracks[session.trackPath])
|
||||
val (track, artists, album) = ensureAggregates(identity)
|
||||
track.listenedSeconds += overlap
|
||||
artists.forEach { it.listenedSeconds += overlap }
|
||||
album.listenedSeconds += overlap
|
||||
tracksPlayed += identity.trackKey
|
||||
listenedSeconds += overlap
|
||||
buckets.forEach { bucket ->
|
||||
bucket.listenedSeconds += overlapSeconds(
|
||||
segment,
|
||||
bucket.startAt,
|
||||
min(bucket.endAt, now + 1),
|
||||
)
|
||||
}
|
||||
var day = startOfDay(max(segment.startedAt, rangeStartAt))
|
||||
val dayEnd = min(segment.lastObservedAt, now)
|
||||
while (day <= dayEnd) {
|
||||
val nextDay = addDays(day, 1)
|
||||
if (overlapSeconds(segment, day, nextDay) > 0) activeDays += day
|
||||
day = nextDay
|
||||
}
|
||||
}
|
||||
|
||||
for (session in sessions) {
|
||||
val qualifiedAt = session.qualifiedAt ?: continue
|
||||
if (qualifiedAt < rangeStartAt || qualifiedAt > now) continue
|
||||
val identity = identity(session, activeTracks[session.trackPath])
|
||||
val (track, artists, album) = ensureAggregates(identity)
|
||||
track.qualifiedPlays += 1
|
||||
artists.forEach { it.qualifiedPlays += 1 }
|
||||
album.qualifiedPlays += 1
|
||||
qualifiedPlays += 1
|
||||
buckets.firstOrNull { qualifiedAt >= it.startAt && qualifiedAt < it.endAt }
|
||||
?.let { it.qualifiedPlays += 1 }
|
||||
}
|
||||
|
||||
val trackComparator = aggregateComparator<TrackAggregate>(
|
||||
rankingMetric,
|
||||
{ it.qualifiedPlays },
|
||||
{ it.listenedSeconds },
|
||||
{ "${it.title}\u0000${it.artist}" },
|
||||
)
|
||||
val artistComparator = aggregateComparator<ArtistAggregate>(
|
||||
rankingMetric,
|
||||
{ it.qualifiedPlays },
|
||||
{ it.listenedSeconds },
|
||||
{ it.artist },
|
||||
)
|
||||
val albumComparator = aggregateComparator<AlbumAggregate>(
|
||||
rankingMetric,
|
||||
{ it.qualifiedPlays },
|
||||
{ it.listenedSeconds },
|
||||
{ "${it.album}\u0000${it.artist}" },
|
||||
)
|
||||
|
||||
return dashboardMap(
|
||||
meta = meta,
|
||||
enabled = enabled,
|
||||
range = range,
|
||||
rankingMetric = rankingMetric,
|
||||
rangeStartAt = rangeStartAt,
|
||||
rangeEndAt = now,
|
||||
granularity = granularity,
|
||||
summary = mapOf(
|
||||
"listenedSeconds" to listenedSeconds,
|
||||
"qualifiedPlays" to qualifiedPlays.toDouble(),
|
||||
"tracksPlayed" to tracksPlayed.size.toDouble(),
|
||||
"activeDays" to activeDays.size.toDouble(),
|
||||
),
|
||||
buckets = buckets,
|
||||
tracks = trackAggregates.values.sortedWith(trackComparator).take(TOP_LIMIT),
|
||||
artists = artistAggregates.values.sortedWith(artistComparator).take(TOP_LIMIT),
|
||||
albums = albumAggregates.values.sortedWith(albumComparator).take(TOP_LIMIT),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun ensureMeta(dao: UserDao): ListeningHistoryMetaEntity {
|
||||
val existing = dao.getListeningHistoryMeta()
|
||||
if (existing != null) return existing
|
||||
val created = ListeningHistoryMetaEntity(generation = UUID.randomUUID().toString())
|
||||
dao.putListeningHistoryMeta(created)
|
||||
return dao.getListeningHistoryMeta() ?: created
|
||||
}
|
||||
|
||||
private suspend fun listeningEnabled(dao: UserDao): Boolean =
|
||||
dao.getSetting(LISTENING_HISTORY_ENABLED_KEY) != "0"
|
||||
}
|
||||
|
||||
private fun sessionQualifies(
|
||||
listenedSeconds: Double,
|
||||
durationSeconds: Double,
|
||||
finalizeSession: Boolean,
|
||||
completedNaturally: Boolean,
|
||||
): Boolean {
|
||||
if (durationSeconds > 0 && durationSeconds < LISTENING_QUALIFICATION_SECONDS) {
|
||||
if (!finalizeSession || !completedNaturally) return false
|
||||
val tolerance = min(
|
||||
SHORT_TRACK_COMPLETION_TOLERANCE_SECONDS,
|
||||
durationSeconds * SHORT_TRACK_COMPLETION_TOLERANCE_RATIO,
|
||||
)
|
||||
return listenedSeconds >= durationSeconds - tolerance
|
||||
}
|
||||
return listenedSeconds >= LISTENING_QUALIFICATION_SECONDS
|
||||
}
|
||||
|
||||
private fun statusMap(meta: ListeningHistoryMetaEntity, enabled: Boolean): Map<String, Any?> =
|
||||
mapOf(
|
||||
"generation" to meta.generation,
|
||||
"startedAt" to meta.startedAt?.toDouble(),
|
||||
"enabled" to enabled,
|
||||
)
|
||||
|
||||
private fun checkpointMap(
|
||||
accepted: Boolean,
|
||||
qualifiedNow: Boolean,
|
||||
meta: ListeningHistoryMetaEntity,
|
||||
enabled: Boolean,
|
||||
): Map<String, Any?> =
|
||||
mapOf(
|
||||
"accepted" to accepted,
|
||||
"qualifiedNow" to qualifiedNow,
|
||||
"status" to statusMap(meta, enabled),
|
||||
)
|
||||
|
||||
private fun emptyDashboard(
|
||||
meta: ListeningHistoryMetaEntity,
|
||||
enabled: Boolean,
|
||||
range: String,
|
||||
rankingMetric: String,
|
||||
rangeEndAt: Long,
|
||||
granularity: String,
|
||||
buckets: List<ActivityBucket>,
|
||||
): Map<String, Any?> =
|
||||
dashboardMap(
|
||||
meta = meta,
|
||||
enabled = enabled,
|
||||
range = range,
|
||||
rankingMetric = rankingMetric,
|
||||
rangeStartAt = rangeStart(range, rangeEndAt, meta.startedAt),
|
||||
rangeEndAt = rangeEndAt,
|
||||
granularity = granularity,
|
||||
summary = mapOf(
|
||||
"listenedSeconds" to 0.0,
|
||||
"qualifiedPlays" to 0.0,
|
||||
"tracksPlayed" to 0.0,
|
||||
"activeDays" to 0.0,
|
||||
),
|
||||
buckets = buckets,
|
||||
tracks = emptyList(),
|
||||
artists = emptyList(),
|
||||
albums = emptyList(),
|
||||
)
|
||||
|
||||
private fun dashboardMap(
|
||||
meta: ListeningHistoryMetaEntity,
|
||||
enabled: Boolean,
|
||||
range: String,
|
||||
rankingMetric: String,
|
||||
rangeStartAt: Long?,
|
||||
rangeEndAt: Long,
|
||||
granularity: String,
|
||||
summary: Map<String, Any?>,
|
||||
buckets: List<ActivityBucket>,
|
||||
tracks: List<TrackAggregate>,
|
||||
artists: List<ArtistAggregate>,
|
||||
albums: List<AlbumAggregate>,
|
||||
): Map<String, Any?> =
|
||||
mapOf(
|
||||
"status" to statusMap(meta, enabled),
|
||||
"range" to range,
|
||||
"rankingMetric" to rankingMetric,
|
||||
"rangeStartAt" to rangeStartAt?.toDouble(),
|
||||
"rangeEndAt" to rangeEndAt.toDouble(),
|
||||
"granularity" to granularity,
|
||||
"summary" to summary,
|
||||
"activity" to buckets.map { bucket ->
|
||||
mapOf(
|
||||
"startAt" to bucket.startAt.toDouble(),
|
||||
"endAt" to bucket.endAt.toDouble(),
|
||||
"label" to bucket.label,
|
||||
"listenedSeconds" to bucket.listenedSeconds,
|
||||
"qualifiedPlays" to bucket.qualifiedPlays.toDouble(),
|
||||
)
|
||||
},
|
||||
"topTracks" to tracks.map { aggregate ->
|
||||
mapOf(
|
||||
"key" to aggregate.key,
|
||||
"trackPath" to aggregate.trackPath,
|
||||
"title" to aggregate.title,
|
||||
"artist" to aggregate.artist,
|
||||
"album" to aggregate.album,
|
||||
"artworkHash" to aggregate.artworkHash,
|
||||
"sourceType" to aggregate.sourceType,
|
||||
"sourceId" to aggregate.sourceId?.toDouble(),
|
||||
"artworkSourceId" to aggregate.artworkSourceId,
|
||||
"listenedSeconds" to aggregate.listenedSeconds,
|
||||
"qualifiedPlays" to aggregate.qualifiedPlays.toDouble(),
|
||||
"available" to aggregate.available,
|
||||
)
|
||||
},
|
||||
"topArtists" to artists.map { aggregate ->
|
||||
mapOf(
|
||||
"key" to aggregate.key,
|
||||
"artist" to aggregate.artist,
|
||||
"artworkHash" to aggregate.artworkHash,
|
||||
"sourceType" to aggregate.sourceType,
|
||||
"sourceId" to aggregate.sourceId?.toDouble(),
|
||||
"artworkSourceId" to aggregate.artworkSourceId,
|
||||
"listenedSeconds" to aggregate.listenedSeconds,
|
||||
"qualifiedPlays" to aggregate.qualifiedPlays.toDouble(),
|
||||
"available" to aggregate.available,
|
||||
)
|
||||
},
|
||||
"topAlbums" to albums.map { aggregate ->
|
||||
mapOf(
|
||||
"key" to aggregate.key,
|
||||
"album" to aggregate.album,
|
||||
"artist" to aggregate.artist,
|
||||
"artworkHash" to aggregate.artworkHash,
|
||||
"sourceType" to aggregate.sourceType,
|
||||
"sourceId" to aggregate.sourceId?.toDouble(),
|
||||
"artworkSourceId" to aggregate.artworkSourceId,
|
||||
"listenedSeconds" to aggregate.listenedSeconds,
|
||||
"qualifiedPlays" to aggregate.qualifiedPlays.toDouble(),
|
||||
"available" to aggregate.available,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
private fun identity(
|
||||
session: ListeningSessionEntity,
|
||||
current: ActiveTrackView?,
|
||||
): ListeningIdentity {
|
||||
val available = current != null
|
||||
return ListeningIdentity(
|
||||
trackKey = "track:${session.trackPath}",
|
||||
trackPath = current?.path,
|
||||
title = current?.title?.trim()?.takeIf { it.isNotEmpty() } ?: session.title,
|
||||
artist = current?.artist?.trim()?.takeIf { it.isNotEmpty() } ?: session.artist,
|
||||
artistNamesJson = current?.artistNamesJson ?: session.artistNamesJson,
|
||||
album = current?.album?.trim()?.takeIf { it.isNotEmpty() } ?: session.album,
|
||||
albumArtist = current?.albumArtist?.trim()?.takeIf { it.isNotEmpty() } ?: session.albumArtist,
|
||||
albumArtistNamesJson = current?.albumArtistNamesJson ?: session.albumArtistNamesJson,
|
||||
albumKey = current?.albumIdentityKey ?: session.albumIdentityKey,
|
||||
artworkHash = current?.artworkHash ?: session.artworkHash,
|
||||
sourceType = current?.sourceType ?: session.sourceType,
|
||||
sourceId = current?.sourceId ?: session.sourceId,
|
||||
artworkSourceId = current?.artworkSourceId ?: session.artworkSourceId,
|
||||
available = available,
|
||||
)
|
||||
}
|
||||
|
||||
private fun browseArtists(identity: ListeningIdentity, groupingMode: String): List<String> {
|
||||
val strict = identity.albumArtist?.trim()?.takeIf { it.isNotEmpty() } ?: identity.artist
|
||||
if (groupingMode == "fileTags") return listOf(strict.ifBlank { "Unknown Artist" })
|
||||
val result = LinkedHashMap<String, String>()
|
||||
fun add(value: String) {
|
||||
val display = normalizeDisplay(value)
|
||||
val key = normalizeKey(display)
|
||||
if (key.isNotEmpty()) result.putIfAbsent(key, display)
|
||||
}
|
||||
val albumNames = deserializeArtistNames(identity.albumArtistNamesJson)
|
||||
val trackNames = deserializeArtistNames(identity.artistNamesJson)
|
||||
val primary = albumNames.firstOrNull()
|
||||
?: splitArtists(identity.albumArtist.orEmpty(), splitAmpersand = false).firstOrNull()
|
||||
?: trackNames.firstOrNull()
|
||||
?: splitArtists(identity.artist, splitAmpersand = true).firstOrNull()
|
||||
?: "Unknown Artist"
|
||||
add(primary)
|
||||
val artists = trackNames.ifEmpty { splitArtists(identity.artist, splitAmpersand = true) }
|
||||
artists.forEach(::add)
|
||||
if (artists.isEmpty()) {
|
||||
albumNames.ifEmpty { splitArtists(identity.albumArtist.orEmpty(), splitAmpersand = false) }
|
||||
.forEach(::add)
|
||||
}
|
||||
return result.values.toList().ifEmpty { listOf("Unknown Artist") }
|
||||
}
|
||||
|
||||
private fun splitArtists(raw: String, splitAmpersand: Boolean): List<String> {
|
||||
var unified = normalizeDisplay(raw)
|
||||
.replace(Regex("\\s*;\\s*"), ",")
|
||||
.replace(Regex("\\s+[x×]\\s+", RegexOption.IGNORE_CASE), ",")
|
||||
.replace(Regex("\\s+(?:feat\\.?|ft\\.?|featuring|with)\\s+", RegexOption.IGNORE_CASE), ",")
|
||||
if (splitAmpersand) unified = unified.replace(Regex("\\s+&\\s+"), ",")
|
||||
val result = LinkedHashMap<String, String>()
|
||||
unified.split(',').forEach { part ->
|
||||
val display = normalizeDisplay(part)
|
||||
val key = normalizeKey(display)
|
||||
if (key.isNotEmpty()) result.putIfAbsent(key, display)
|
||||
}
|
||||
return result.values.toList()
|
||||
}
|
||||
|
||||
private fun normalizeDisplay(value: String): String = value.replace(Regex("\\s+"), " ").trim()
|
||||
private fun normalizeKey(value: String): String = normalizeDisplay(value).lowercase(Locale.ROOT)
|
||||
|
||||
private fun overlapSeconds(segment: ListeningSegmentEntity, startAt: Long, endAt: Long): Double {
|
||||
val segmentStart = segment.startedAt
|
||||
val segmentEnd = max(segmentStart, segment.lastObservedAt)
|
||||
val listened = segment.listenedSeconds.finiteNonNegative()
|
||||
if (listened <= 0 || segmentEnd <= startAt || segmentStart >= endAt) return 0.0
|
||||
val wallDuration = segmentEnd - segmentStart
|
||||
if (wallDuration <= 0) return if (segmentStart in startAt until endAt) listened else 0.0
|
||||
val overlap = max(0L, min(segmentEnd, endAt) - max(segmentStart, startAt))
|
||||
return listened * min(1.0, overlap.toDouble() / wallDuration.toDouble())
|
||||
}
|
||||
|
||||
private fun rangeStart(range: String, now: Long, baseline: Long?): Long? {
|
||||
if (range == "all") return baseline
|
||||
val today = startOfDay(now)
|
||||
return when (range) {
|
||||
"7d" -> addDays(today, -6)
|
||||
"1y" -> addDays(today, -364)
|
||||
else -> addDays(today, -29)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildBuckets(
|
||||
range: String,
|
||||
rangeStartAt: Long?,
|
||||
now: Long,
|
||||
): Pair<String, List<ActivityBucket>> {
|
||||
val granularity = when (range) {
|
||||
"7d", "30d" -> "day"
|
||||
"1y" -> "week"
|
||||
else -> "month"
|
||||
}
|
||||
if (rangeStartAt == null) return granularity to emptyList()
|
||||
val labelFormat = SimpleDateFormat(
|
||||
if (granularity == "month") "MMM yyyy" else "MMM d",
|
||||
Locale.getDefault(),
|
||||
)
|
||||
val buckets = mutableListOf<ActivityBucket>()
|
||||
var cursor = if (granularity == "month") startOfMonth(rangeStartAt) else startOfDay(rangeStartAt)
|
||||
while (cursor <= now) {
|
||||
val end = when (granularity) {
|
||||
"week" -> addDays(cursor, 7)
|
||||
"month" -> addMonths(cursor, 1)
|
||||
else -> addDays(cursor, 1)
|
||||
}
|
||||
buckets += ActivityBucket(cursor, end, labelFormat.format(Date(cursor)))
|
||||
cursor = end
|
||||
}
|
||||
return granularity to buckets
|
||||
}
|
||||
|
||||
private fun startOfDay(timestamp: Long): Long =
|
||||
Calendar.getInstance().apply {
|
||||
timeInMillis = timestamp
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
|
||||
private fun startOfMonth(timestamp: Long): Long =
|
||||
Calendar.getInstance().apply {
|
||||
timeInMillis = timestamp
|
||||
set(Calendar.DAY_OF_MONTH, 1)
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
|
||||
private fun addDays(timestamp: Long, days: Int): Long =
|
||||
Calendar.getInstance().apply {
|
||||
timeInMillis = timestamp
|
||||
add(Calendar.DAY_OF_MONTH, days)
|
||||
}.timeInMillis
|
||||
|
||||
private fun addMonths(timestamp: Long, months: Int): Long =
|
||||
Calendar.getInstance().apply {
|
||||
timeInMillis = timestamp
|
||||
add(Calendar.MONTH, months)
|
||||
}.timeInMillis
|
||||
|
||||
private fun <T> aggregateComparator(
|
||||
metric: String,
|
||||
plays: (T) -> Long,
|
||||
seconds: (T) -> Double,
|
||||
label: (T) -> String,
|
||||
): Comparator<T> = Comparator { left, right ->
|
||||
val primary = if (metric == "time") {
|
||||
seconds(right).compareTo(seconds(left))
|
||||
} else {
|
||||
plays(right).compareTo(plays(left))
|
||||
}
|
||||
if (primary != 0) return@Comparator primary
|
||||
val secondary = if (metric == "time") {
|
||||
plays(right).compareTo(plays(left))
|
||||
} else {
|
||||
seconds(right).compareTo(seconds(left))
|
||||
}
|
||||
if (secondary != 0) return@Comparator secondary
|
||||
label(left).compareTo(label(right), ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun maxNullable(left: Long?, right: Long?): Long? = when {
|
||||
left == null -> right
|
||||
right == null -> left
|
||||
else -> max(left, right)
|
||||
}
|
||||
|
||||
private fun Double.finiteNonNegative(): Double =
|
||||
if (isFinite()) coerceAtLeast(0.0) else 0.0
|
||||
|
||||
private fun Map<String, Any?>.string(key: String): String = (this[key] as? String)?.trim().orEmpty()
|
||||
private fun Map<String, Any?>.boolean(key: String): Boolean = this[key] == true
|
||||
private fun Map<String, Any?>.double(key: String): Double = (this[key] as? Number)?.toDouble() ?: 0.0
|
||||
private fun Map<String, Any?>.long(key: String, fallback: Long): Long =
|
||||
(this[key] as? Number)?.toDouble()?.takeIf(Double::isFinite)?.toLong() ?: fallback
|
||||
+170
-1
@@ -9,6 +9,8 @@ import androidx.room.Query
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Upsert
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
data class RemotePlaylistSyncPlan(
|
||||
val playlist: PlaylistEntity,
|
||||
@@ -177,6 +179,93 @@ interface UserDao {
|
||||
@Upsert
|
||||
suspend fun putPlaybackHistories(history: List<PlaybackHistoryEntity>)
|
||||
|
||||
@Query("SELECT * FROM listening_history_meta WHERE id = 1")
|
||||
suspend fun getListeningHistoryMeta(): ListeningHistoryMetaEntity?
|
||||
|
||||
@Upsert
|
||||
suspend fun putListeningHistoryMeta(meta: ListeningHistoryMetaEntity)
|
||||
|
||||
@Query("SELECT * FROM listening_sessions WHERE session_key = :sessionKey")
|
||||
suspend fun getListeningSession(sessionKey: String): ListeningSessionEntity?
|
||||
|
||||
@Upsert
|
||||
suspend fun putListeningSession(session: ListeningSessionEntity)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM listening_sessions
|
||||
WHERE generation = :generation
|
||||
AND (
|
||||
session_key IN (
|
||||
SELECT session_key FROM listening_segments
|
||||
WHERE generation = :generation
|
||||
AND last_observed_at >= :startAt
|
||||
AND started_at <= :endAt
|
||||
)
|
||||
OR (qualified_at >= :startAt AND qualified_at <= :endAt)
|
||||
)
|
||||
ORDER BY started_at
|
||||
""",
|
||||
)
|
||||
suspend fun getListeningSessionsInRange(
|
||||
generation: String,
|
||||
startAt: Long,
|
||||
endAt: Long,
|
||||
): List<ListeningSessionEntity>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
UPDATE listening_sessions
|
||||
SET qualified_at = :qualifiedAt
|
||||
WHERE session_key = :sessionKey
|
||||
AND generation = :generation
|
||||
AND qualified_at IS NULL
|
||||
""",
|
||||
)
|
||||
suspend fun qualifyListeningSession(
|
||||
sessionKey: String,
|
||||
generation: String,
|
||||
qualifiedAt: Long,
|
||||
): Int
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM listening_segments
|
||||
WHERE generation = :generation
|
||||
AND last_observed_at >= :startAt
|
||||
AND started_at <= :endAt
|
||||
ORDER BY started_at
|
||||
""",
|
||||
)
|
||||
suspend fun getListeningSegmentsInRange(
|
||||
generation: String,
|
||||
startAt: Long,
|
||||
endAt: Long,
|
||||
): List<ListeningSegmentEntity>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM listening_segments
|
||||
WHERE session_key = :sessionKey AND segment_key = :segmentKey
|
||||
""",
|
||||
)
|
||||
suspend fun getListeningSegment(
|
||||
sessionKey: String,
|
||||
segmentKey: String,
|
||||
): ListeningSegmentEntity?
|
||||
|
||||
@Upsert
|
||||
suspend fun putListeningSegment(segment: ListeningSegmentEntity)
|
||||
|
||||
@Query("DELETE FROM listening_segments")
|
||||
suspend fun clearListeningSegments()
|
||||
|
||||
@Query("DELETE FROM listening_sessions")
|
||||
suspend fun clearListeningSessions()
|
||||
|
||||
@Query("DELETE FROM listening_history_meta")
|
||||
suspend fun clearListeningHistoryMeta()
|
||||
|
||||
@Query("SELECT * FROM remote_sources ORDER BY created_at, id")
|
||||
suspend fun getRemoteSources(): List<RemoteSourceEntity>
|
||||
|
||||
@@ -423,6 +512,9 @@ interface UserDao {
|
||||
PlaylistTrackEntity::class,
|
||||
FavoriteEntity::class,
|
||||
PlaybackHistoryEntity::class,
|
||||
ListeningHistoryMetaEntity::class,
|
||||
ListeningSessionEntity::class,
|
||||
ListeningSegmentEntity::class,
|
||||
RemoteSourceEntity::class,
|
||||
FavoriteTombstoneEntity::class,
|
||||
PendingFavoriteEntity::class,
|
||||
@@ -433,9 +525,86 @@ interface UserDao {
|
||||
PlaybackOriginalQueueEntryEntity::class,
|
||||
SnapshotMetadataEntity::class,
|
||||
],
|
||||
version = 1,
|
||||
version = 2,
|
||||
exportSchema = true,
|
||||
)
|
||||
abstract class AstraUserDatabase : RoomDatabase() {
|
||||
abstract fun userDao(): UserDao
|
||||
}
|
||||
|
||||
internal val USER_MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `listening_history_meta` (
|
||||
`id` INTEGER NOT NULL,
|
||||
`generation` TEXT NOT NULL,
|
||||
`started_at` INTEGER,
|
||||
PRIMARY KEY(`id`)
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `listening_sessions` (
|
||||
`session_key` TEXT NOT NULL,
|
||||
`generation` TEXT NOT NULL,
|
||||
`track_path` TEXT NOT NULL,
|
||||
`title` TEXT NOT NULL,
|
||||
`artist` TEXT NOT NULL,
|
||||
`artist_names_json` TEXT,
|
||||
`album` TEXT NOT NULL,
|
||||
`album_artist` TEXT,
|
||||
`album_artist_names_json` TEXT,
|
||||
`album_identity_key` TEXT NOT NULL,
|
||||
`artwork_hash` TEXT,
|
||||
`source_type` TEXT NOT NULL,
|
||||
`source_id` INTEGER,
|
||||
`artwork_source_id` TEXT,
|
||||
`duration_seconds` REAL NOT NULL,
|
||||
`started_at` INTEGER NOT NULL,
|
||||
`ended_at` INTEGER,
|
||||
`listened_seconds` REAL NOT NULL,
|
||||
`qualified_at` INTEGER,
|
||||
PRIMARY KEY(`session_key`)
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `listening_segments` (
|
||||
`session_key` TEXT NOT NULL,
|
||||
`segment_key` TEXT NOT NULL,
|
||||
`generation` TEXT NOT NULL,
|
||||
`started_at` INTEGER NOT NULL,
|
||||
`last_observed_at` INTEGER NOT NULL,
|
||||
`ended_at` INTEGER,
|
||||
`listened_seconds` REAL NOT NULL,
|
||||
PRIMARY KEY(`session_key`, `segment_key`),
|
||||
FOREIGN KEY(`session_key`) REFERENCES `listening_sessions`(`session_key`)
|
||||
ON UPDATE NO ACTION ON DELETE CASCADE
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
database.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_listening_sessions_generation_started_at` " +
|
||||
"ON `listening_sessions` (`generation`, `started_at`)",
|
||||
)
|
||||
database.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_listening_sessions_generation_qualified_at` " +
|
||||
"ON `listening_sessions` (`generation`, `qualified_at`)",
|
||||
)
|
||||
database.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_listening_sessions_track_path` " +
|
||||
"ON `listening_sessions` (`track_path`)",
|
||||
)
|
||||
database.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_listening_segments_generation_started_at_last_observed_at` " +
|
||||
"ON `listening_segments` (`generation`, `started_at`, `last_observed_at`)",
|
||||
)
|
||||
database.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_listening_segments_session_key` " +
|
||||
"ON `listening_segments` (`session_key`)",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+65
@@ -93,6 +93,71 @@ data class PlaybackHistoryEntity(
|
||||
@ColumnInfo(name = "play_count") val playCount: Long = 1,
|
||||
)
|
||||
|
||||
@Entity(tableName = "listening_history_meta")
|
||||
data class ListeningHistoryMetaEntity(
|
||||
@PrimaryKey val id: Int = 1,
|
||||
val generation: String,
|
||||
@ColumnInfo(name = "started_at") val startedAt: Long? = null,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "listening_sessions",
|
||||
indices = [
|
||||
Index(value = ["generation", "started_at"]),
|
||||
Index(value = ["generation", "qualified_at"]),
|
||||
Index(value = ["track_path"]),
|
||||
],
|
||||
)
|
||||
data class ListeningSessionEntity(
|
||||
@PrimaryKey
|
||||
@ColumnInfo(name = "session_key")
|
||||
val sessionKey: String,
|
||||
val generation: String,
|
||||
@ColumnInfo(name = "track_path") val trackPath: String,
|
||||
val title: String,
|
||||
val artist: String,
|
||||
@ColumnInfo(name = "artist_names_json") val artistNamesJson: String? = null,
|
||||
val album: String,
|
||||
@ColumnInfo(name = "album_artist") val albumArtist: String? = null,
|
||||
@ColumnInfo(name = "album_artist_names_json") val albumArtistNamesJson: String? = null,
|
||||
@ColumnInfo(name = "album_identity_key") val albumIdentityKey: String,
|
||||
@ColumnInfo(name = "artwork_hash") val artworkHash: String? = null,
|
||||
@ColumnInfo(name = "source_type") val sourceType: String,
|
||||
@ColumnInfo(name = "source_id") val sourceId: Long? = null,
|
||||
@ColumnInfo(name = "artwork_source_id") val artworkSourceId: String? = null,
|
||||
@ColumnInfo(name = "duration_seconds") val durationSeconds: Double,
|
||||
@ColumnInfo(name = "started_at") val startedAt: Long,
|
||||
@ColumnInfo(name = "ended_at") val endedAt: Long? = null,
|
||||
@ColumnInfo(name = "listened_seconds") val listenedSeconds: Double = 0.0,
|
||||
@ColumnInfo(name = "qualified_at") val qualifiedAt: Long? = null,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "listening_segments",
|
||||
primaryKeys = ["session_key", "segment_key"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = ListeningSessionEntity::class,
|
||||
parentColumns = ["session_key"],
|
||||
childColumns = ["session_key"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [
|
||||
Index(value = ["generation", "started_at", "last_observed_at"]),
|
||||
Index(value = ["session_key"]),
|
||||
],
|
||||
)
|
||||
data class ListeningSegmentEntity(
|
||||
@ColumnInfo(name = "session_key") val sessionKey: String,
|
||||
@ColumnInfo(name = "segment_key") val segmentKey: String,
|
||||
val generation: String,
|
||||
@ColumnInfo(name = "started_at") val startedAt: Long,
|
||||
@ColumnInfo(name = "last_observed_at") val lastObservedAt: Long,
|
||||
@ColumnInfo(name = "ended_at") val endedAt: Long? = null,
|
||||
@ColumnInfo(name = "listened_seconds") val listenedSeconds: Double = 0.0,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "remote_sources",
|
||||
indices = [Index(value = ["type", "name"])],
|
||||
|
||||
@@ -389,6 +389,10 @@ declare class AstraLibraryDataModuleType extends NativeModule<AstraLibraryDataEv
|
||||
values: Record<string, unknown>
|
||||
): Promise<NativePlaybackWindow<T> | null>;
|
||||
recordTrackPlayed(path: string): Promise<boolean>;
|
||||
getListeningHistoryStatus<T>(): Promise<T>;
|
||||
checkpointListeningSession<T>(payload: Record<string, unknown>): Promise<T>;
|
||||
getListeningStatsDashboard<T>(query: Record<string, unknown>): Promise<T>;
|
||||
clearDetailedListeningHistory<T>(): Promise<T>;
|
||||
getRecentlyPlayed<T>(limit: number): Promise<T[]>;
|
||||
listRemoteSources<T>(): Promise<T[]>;
|
||||
getRemoteSource<T>(sourceId: number): Promise<T | null>;
|
||||
|
||||
Reference in New Issue
Block a user