add stats tracking + stats page + fix scrolling bug in dynamic playlists tray

This commit is contained in:
Boof2015
2026-07-28 19:25:30 -04:00
parent b28a018ca0
commit 00d0fc4b2f
40 changed files with 5280 additions and 126 deletions
@@ -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 ->
@@ -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"
}
}
@@ -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) }
}
@@ -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 =
@@ -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
@@ -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`)",
)
}
}
@@ -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"])],
+4
View File
@@ -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>;