Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
[submodule "third-party/taglib"]
path = third-party/taglib
url = ../taglib.git
url = https://github.com/rRemix/taglib.git
branch = main
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ dependencies {
exclude(group = "xpp3", module = "xpp3")
}
implementation(libs.timber)
implementation(libs.tinypinyin)

debugImplementation(libs.leakcanary)

Expand Down
33 changes: 33 additions & 0 deletions app/src/main/java/remix/myplayer/data/db/DbMigrations.kt
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,37 @@ internal object DbMigrations {
db.execSQL("CREATE TABLE IF NOT EXISTS `Smb` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `alias` TEXT NOT NULL, `domain` TEXT, `account` TEXT NOT NULL, `pwd` TEXT NOT NULL, `server` TEXT NOT NULL, `share` TEXT NOT NULL, `lastUrl` TEXT NOT NULL, `createAt` INTEGER NOT NULL)")
}
}

val migration7to8 = object : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS `play_events` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `schemaVersion` INTEGER NOT NULL, `eventId` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `eventType` TEXT NOT NULL, `canonicalId` TEXT NOT NULL, `audioId` INTEGER, `startedAt` INTEGER NOT NULL, `endedAt` INTEGER NOT NULL, `durationMs` INTEGER NOT NULL, `listenedMs` INTEGER NOT NULL, `listenRatio` REAL NOT NULL, `playScore` REAL NOT NULL, `completed` INTEGER NOT NULL, `source` TEXT NOT NULL, `endReason` TEXT NOT NULL, `titleSnapshot` TEXT NOT NULL, `artistSnapshot` TEXT NOT NULL, `albumSnapshot` TEXT NOT NULL, `genreSnapshot` TEXT, `sourceUri` TEXT, `contentHash` TEXT, `pathHint` TEXT, `year` INTEGER NOT NULL, `month` INTEGER NOT NULL, `day` INTEGER NOT NULL, `hour` INTEGER NOT NULL, `weekday` INTEGER NOT NULL)"
)
db.execSQL("CREATE INDEX IF NOT EXISTS `index_play_events_year` ON `play_events` (`year`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_play_events_canonicalId` ON `play_events` (`canonicalId`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_play_events_audioId` ON `play_events` (`audioId`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_play_events_source` ON `play_events` (`source`)")

db.execSQL("CREATE INDEX IF NOT EXISTS `index_play_events_startedAt` ON `play_events` (`startedAt`)")
}
}

val migration8to9 = object : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE `play_events` ADD COLUMN `songId` TEXT")
db.execSQL("ALTER TABLE `play_events` ADD COLUMN `artistId` TEXT")
db.execSQL("ALTER TABLE `play_events` ADD COLUMN `albumId` TEXT")
db.execSQL("ALTER TABLE `play_events` ADD COLUMN `genreId` TEXT")
db.execSQL("ALTER TABLE `play_events` ADD COLUMN `playlistId` TEXT")
db.execSQL("ALTER TABLE `play_events` ADD COLUMN `mediaType` TEXT")
db.execSQL("ALTER TABLE `play_events` ADD COLUMN `sessionId` TEXT")
db.execSQL("ALTER TABLE `play_events` ADD COLUMN `gapBeforeMs` INTEGER")
db.execSQL("ALTER TABLE `play_events` ADD COLUMN `gapAfterMs` INTEGER")
db.execSQL("ALTER TABLE `play_events` ADD COLUMN `loopCount` INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE `play_events` ADD COLUMN `outputDevice` TEXT")
db.execSQL("ALTER TABLE `play_events` ADD COLUMN `isForeground` INTEGER")
db.execSQL("ALTER TABLE `play_events` ADD COLUMN `decoder` TEXT")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_play_events_eventType` ON `play_events` (`eventType`)")
}
}
}
13 changes: 11 additions & 2 deletions app/src/main/java/remix/myplayer/data/db/room/AppDatabase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,18 @@ import remix.myplayer.data.db.DbMigrations.migration3to4
import remix.myplayer.data.db.DbMigrations.migration4to5
import remix.myplayer.data.db.DbMigrations.migration5to6
import remix.myplayer.data.db.DbMigrations.migration6to7
import remix.myplayer.data.db.DbMigrations.migration7to8
import remix.myplayer.data.db.DbMigrations.migration8to9
import remix.myplayer.data.db.room.dao.HistoryDao
import remix.myplayer.data.db.room.dao.MetaDataCacheDao
import remix.myplayer.data.db.room.dao.PlayEventDao
import remix.myplayer.data.db.room.dao.PlayListDao
import remix.myplayer.data.db.room.dao.PlayQueueDao
import remix.myplayer.data.db.room.dao.SmbDao
import remix.myplayer.data.db.room.dao.WebDavDao
import remix.myplayer.data.db.room.entity.History
import remix.myplayer.data.db.room.entity.MetaDataCache
import remix.myplayer.data.db.room.entity.PlayEvent
import remix.myplayer.data.db.room.entity.PlayList
import remix.myplayer.data.db.room.entity.PlayQueue
import remix.myplayer.data.db.room.entity.Smb
Expand All @@ -39,7 +43,8 @@ import timber.log.Timber
History::class,
WebDav::class,
Smb::class,
MetaDataCache::class
MetaDataCache::class,
PlayEvent::class
], version = AppDatabase.VERSION, exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
Expand All @@ -50,6 +55,8 @@ abstract class AppDatabase : RoomDatabase() {

abstract fun historyDao(): HistoryDao

abstract fun playEventDao(): PlayEventDao

abstract fun webDavDao(): WebDavDao

abstract fun smbDao(): SmbDao
Expand All @@ -58,7 +65,7 @@ abstract class AppDatabase : RoomDatabase() {

companion object {

const val VERSION = 7
const val VERSION = 9

@Volatile
private var INSTANCE: AppDatabase? = null
Expand All @@ -83,6 +90,8 @@ abstract class AppDatabase : RoomDatabase() {
.addMigrations(migration4to5)
.addMigrations(migration5to6)
.addMigrations(migration6to7)
.addMigrations(migration7to8)
.addMigrations(migration8to9)
.build()
database.invalidationTracker.addObserver(object :
InvalidationTracker.Observer(PlayList.TABLE_NAME, PlayQueue.TABLE_NAME) {
Expand Down
202 changes: 202 additions & 0 deletions app/src/main/java/remix/myplayer/data/db/room/dao/PlayEventDao.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package remix.myplayer.data.db.room.dao

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.OnConflictStrategy.Companion.REPLACE
import remix.myplayer.data.db.room.entity.PlayEvent

@Dao
interface PlayEventDao {

@Insert(onConflict = REPLACE)
suspend fun insert(event: PlayEvent): Long

// 导出用:包税全部事件类型(playback + song_added)
@Query("SELECT * FROM play_events WHERE year = :year ORDER BY startedAt DESC")
suspend fun selectByYear(year: Int): List<PlayEvent>

// 报告年份选择:只统计播放事件
@Query("SELECT DISTINCT year FROM play_events WHERE eventType = 'playback' ORDER BY year DESC")
suspend fun distinctYears(): List<Int>

// ---- 播放类聚合(只统计 playback)----
@Query("SELECT COUNT(*) FROM play_events WHERE eventType = 'playback' AND year = :year")
suspend fun countByYear(year: Int): Int

@Query("SELECT COUNT(*) FROM play_events WHERE eventType = 'song_added' AND canonicalId = :canonicalId")
suspend fun countSongAdded(canonicalId: String): Int

@Query("SELECT COUNT(*) FROM play_events WHERE eventType = 'playback' AND year = :year")
suspend fun countPlayable(year: Int): Int

@Query("SELECT COALESCE(SUM(playScore), 0) FROM play_events WHERE eventType = 'playback' AND year = :year")
suspend fun sumPlayScore(year: Int): Double

@Query("SELECT COALESCE(SUM(listenedMs), 0) FROM play_events WHERE eventType = 'playback' AND year = :year")
suspend fun sumListenMs(year: Int): Long

@Query("SELECT COUNT(*) FROM play_events WHERE eventType = 'playback' AND year = :year AND completed = 1")
suspend fun countCompleted(year: Int): Int

@Query("SELECT COUNT(DISTINCT day) FROM play_events WHERE eventType = 'playback' AND year = :year")
suspend fun countListenDays(year: Int): Int

@Query("SELECT COUNT(DISTINCT canonicalId) FROM play_events WHERE eventType = 'playback' AND year = :year")
suspend fun countDistinctSongs(year: Int): Int

@Query("SELECT COUNT(DISTINCT artistSnapshot) FROM play_events WHERE eventType = 'playback' AND year = :year")
suspend fun countDistinctArtists(year: Int): Int

@Query("SELECT COUNT(DISTINCT albumSnapshot) FROM play_events WHERE eventType = 'playback' AND year = :year")
suspend fun countDistinctAlbums(year: Int): Int

// 当年首次收听的歌曲数(该歌曲最早一次播放发生在该年)
@Query(
"""
SELECT COUNT(*) FROM (
SELECT canonicalId, MIN(startedAt) AS firstAt
FROM play_events
WHERE eventType = 'playback'
GROUP BY canonicalId
HAVING CAST(strftime('%Y', firstAt / 1000, 'unixepoch') AS INTEGER) = :year
)
"""
)
suspend fun countFirstListenedSongs(year: Int): Int

// 当年新增歌曲数(song_added 且 year = 该年)
@Query("SELECT COUNT(*) FROM play_events WHERE eventType = 'song_added' AND year = :year")
suspend fun countAddedSongs(year: Int): Int

@Query(
"""
SELECT canonicalId AS canonicalId,
(SELECT titleSnapshot FROM play_events e2 WHERE e2.canonicalId = e1.canonicalId AND e2.eventType = 'playback' AND e2.year = :year ORDER BY e2.startedAt DESC LIMIT 1) AS title,
(SELECT artistSnapshot FROM play_events e2 WHERE e2.canonicalId = e1.canonicalId AND e2.eventType = 'playback' AND e2.year = :year ORDER BY e2.startedAt DESC LIMIT 1) AS artist,
(SELECT albumSnapshot FROM play_events e2 WHERE e2.canonicalId = e1.canonicalId AND e2.eventType = 'playback' AND e2.year = :year ORDER BY e2.startedAt DESC LIMIT 1) AS album,
SUM(listenedMs) AS listenedMs,
SUM(playScore) AS playScore,
COUNT(*) AS plays
FROM play_events e1
WHERE e1.eventType = 'playback' AND e1.year = :year
GROUP BY canonicalId
ORDER BY listenedMs DESC
LIMIT :limit
"""
)
suspend fun topSongs(year: Int, limit: Int): List<TopPlayItem>

@Query(
"""
SELECT artistSnapshot AS name,
SUM(listenedMs) AS listenedMs,
COUNT(*) AS plays,
COUNT(DISTINCT canonicalId) AS songs
FROM play_events
WHERE eventType = 'playback' AND year = :year
GROUP BY artistSnapshot
ORDER BY listenedMs DESC
LIMIT :limit
"""
)
suspend fun topArtists(year: Int, limit: Int): List<TopArtistItem>

@Query(
"""
SELECT albumSnapshot AS name,
SUM(listenedMs) AS listenedMs,
COUNT(*) AS plays,
COUNT(DISTINCT canonicalId) AS songs
FROM play_events
WHERE eventType = 'playback' AND year = :year
GROUP BY albumSnapshot
ORDER BY listenedMs DESC
LIMIT :limit
"""
)
suspend fun topAlbums(year: Int, limit: Int): List<TopAlbumItem>

@Query(
"""
SELECT month AS month, COUNT(*) AS plays, COALESCE(SUM(listenedMs), 0) AS listenedMs
FROM play_events
WHERE eventType = 'playback' AND year = :year
GROUP BY month
ORDER BY month
"""
)
suspend fun monthDistribution(year: Int): List<MonthCount>

@Query(
"""
SELECT hour AS hour, COUNT(*) AS plays
FROM play_events
WHERE eventType = 'playback' AND year = :year
GROUP BY hour
ORDER BY hour
"""
)
suspend fun hourDistribution(year: Int): List<HourCount>

@Query(
"""
SELECT source AS source, COUNT(*) AS plays, COALESCE(SUM(listenedMs), 0) AS listenedMs
FROM play_events
WHERE eventType = 'playback' AND year = :year
GROUP BY source
ORDER BY plays DESC
"""
)
suspend fun sourceBreakdown(year: Int): List<SourceCount>

@Query("SELECT COUNT(*) FROM play_events")
suspend fun countAll(): Int

@Query("DELETE FROM play_events")
suspend fun clear()

@Query("DELETE FROM play_events WHERE year = :year")
suspend fun deleteByYear(year: Int)
}

data class TopPlayItem(
val canonicalId: String,
val title: String,
val artist: String,
val album: String,
val listenedMs: Long,
val playScore: Double,
val plays: Int
)

data class TopArtistItem(
val name: String,
val listenedMs: Long,
val plays: Int,
val songs: Int
)

data class TopAlbumItem(
val name: String,
val listenedMs: Long,
val plays: Int,
val songs: Int
)

data class MonthCount(
val month: Int,
val plays: Int,
val listenedMs: Long
)

data class HourCount(
val hour: Int,
val plays: Int
)

data class SourceCount(
val source: String,
val plays: Int,
val listenedMs: Long
)
68 changes: 68 additions & 0 deletions app/src/main/java/remix/myplayer/data/db/room/entity/PlayEvent.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package remix.myplayer.data.db.room.entity

import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey

/**
* 单次播放事件(v2)。
*
* 字段遵循跨平台协议:eventId/deviceId/schemaVersion/canonicalId 与平台无关。
* startedAt/endedAt 使用 UTC epoch 毫秒。listenRatio/playScore/completed 为派生值。
* v2 新增的可选字段用于更丰富的年度统计(媒体类型/稳定ID/会话/循环/环境占位)。
*/
@Entity(
tableName = "play_events",
indices = [
Index(value = ["year"]),
Index(value = ["canonicalId"]),
Index(value = ["audioId"]),
Index(value = ["source"]),
Index(value = ["startedAt"]),
Index(value = ["eventType"])
]
)
data class PlayEvent(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val schemaVersion: Int = 2,
val eventId: String,
val deviceId: String,
val eventType: String = "playback",
val canonicalId: String,
val audioId: Long?,
val startedAt: Long,
val endedAt: Long,
val durationMs: Long,
val listenedMs: Long,
val listenRatio: Double,
val playScore: Double,
val completed: Boolean,
val source: String,
val endReason: String,
val titleSnapshot: String,
val artistSnapshot: String,
val albumSnapshot: String,
val genreSnapshot: String?,
val sourceUri: String?,
val contentHash: String?,
val pathHint: String?,
val year: Int,
val month: Int,
val day: Int,
val hour: Int,
val weekday: Int,
// ---- v2 扩展(全部可选,向前兼容) ----
val songId: String? = null,
val artistId: String? = null,
val albumId: String? = null,
val genreId: String? = null,
val playlistId: String? = null,
val mediaType: String? = null,
val sessionId: String? = null,
val gapBeforeMs: Long? = null,
val gapAfterMs: Long? = null,
val loopCount: Int = 0,
val outputDevice: String? = null,
val isForeground: Boolean? = null,
val decoder: String? = null
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package remix.myplayer.data.model.audio

/**
* 播放事件结束原因。
*/
enum class PlayEventEndReason(val value: Int) {
NATURAL_END(0),
SKIP_TO_NEXT(1),
SKIP_TO_PREVIOUS(2),
PLAYLIST_CHANGED(3),
STOP(4),
ERROR(5);

companion object {
fun fromValue(value: Int): PlayEventEndReason? = entries.firstOrNull { it.value == value }
}
}
Loading