跳转至

apps\player\src\main\java\cn\nfarms\player\data\LibraryRepo.kt

源码路径:apps\player\src\main\java\cn\nfarms\player\data\LibraryRepo.kt · 231 行

package cn.nfarms.player.data

import cn.nfarms.core.secure.Crypto
import cn.nfarms.core.store.AppFiles
import cn.nfarms.core.store.JsonStore
import cn.nfarms.core.store.ListSerializer
import java.io.File
import kotlinx.serialization.builtins.serializer

/**
 * 曲库 + 播放列表 + 书签的仓库。
 *
 * **所有写操作都同步落盘**(而不是攒批):播放进度这种数据丢了不影响使用,
 * 但「刚加的收藏重启就没了」会让人觉得软件坏了。这里的数据量很小
 * (几千条封顶),每次全量写 JSON 完全可接受。
 */
class LibraryRepo(files: AppFiles) {

    private val store: JsonStore = files.store("library")
    private val mediaDir: File = files.media("audio")

    @Volatile
    private var state: LibraryState = load()

    fun snapshot(): LibraryState = state

    private fun load(): LibraryState =
        store.read(LibraryState.serializer()) ?: LibraryState()

    private fun persist() {
        store.write(LibraryState.serializer(), state)
    }

    /** 把一个音频文件登记进曲库,返回曲目。重复路径会复用已有条目。 */
    fun addFile(file: File, title: String? = null): Track {
        val key = keyOf(file)
        state.tracks.firstOrNull { it.key == key }?.let { return it }
        val t = Track(
            key = key,
            title = title ?: file.nameWithoutExtension,
            path = file.absolutePath,
            sizeBytes = file.length(),
            durationMs = 0,
            lyrics = readSidecarLrc(file),
        )
        state = state.copy(tracks = state.tracks + t)
        persist()
        return t
    }

    fun update(track: Track) {
        state = state.copy(tracks = state.tracks.map { if (it.key == track.key) track else it })
        persist()
    }

    fun updateAll(tracks: List<Track>) {
        val byKey = tracks.associateBy { it.key }
        state = state.copy(tracks = state.tracks.map { byKey[it.key] ?: it })
        persist()
    }

    fun remove(key: String) {
        state = state.copy(
            tracks = state.tracks.filterNot { it.key == key },
            playlists = state.playlists.map { it.copy(keys = it.keys - key) },
            bookmarks = state.bookmarks.filterNot { it.trackKey == key },
        )
        persist()
    }

    fun toggleFavorite(key: String) {
        state = state.copy(
            tracks = state.tracks.map { if (it.key == key) it.copy(favorite = !it.favorite) else it },
        )
        persist()
    }

    fun saveProgress(key: String, positionMs: Long, durationMs: Long) {
        state = state.copy(
            tracks = state.tracks.map {
                if (it.key == key) {
                    // 播到末尾就当作「听完了」,把进度清零 —— 否则下次一进来就从尾巴放
                    val pos = if (durationMs > 0 && positionMs >= durationMs - 3_000) 0 else positionMs
                    it.copy(positionMs = pos, durationMs = durationMs, lastPlayedAt = System.currentTimeMillis())
                } else it
            },
        )
        persist()
    }

    fun setSpeed(speed: Float) {
        state = state.copy(speed = speed)
        persist()
    }

    // ------------------------------------------------------------- 播放列表

    fun createPlaylist(name: String): Playlist {
        val p = Playlist(id = java.util.UUID.randomUUID().toString().take(8), name = name)
        state = state.copy(playlists = state.playlists + p)
        persist()
        return p
    }

    fun addToPlaylist(playlistId: String, keys: List<String>) {
        state = state.copy(
            playlists = state.playlists.map {
                if (it.id == playlistId) {
                    it.copy(keys = (it.keys + keys).distinct(), updatedAt = System.currentTimeMillis())
                } else it
            },
        )
        persist()
    }

    fun removeFromPlaylist(playlistId: String, key: String) {
        state = state.copy(
            playlists = state.playlists.map {
                if (it.id == playlistId) it.copy(keys = it.keys - key, updatedAt = System.currentTimeMillis()) else it
            },
        )
        persist()
    }

    fun deletePlaylist(id: String) {
        state = state.copy(playlists = state.playlists.filterNot { it.id == id })
        persist()
    }

    // --------------------------------------------------------------- 书签

    fun addBookmark(trackKey: String, positionMs: Long, note: String) {
        val b = Bookmark(
            id = java.util.UUID.randomUUID().toString().take(8),
            trackKey = trackKey,
            positionMs = positionMs,
            note = note,
        )
        state = state.copy(bookmarks = state.bookmarks + b)
        persist()
    }

    fun removeBookmark(id: String) {
        state = state.copy(bookmarks = state.bookmarks.filterNot { it.id == id })
        persist()
    }

    // --------------------------------------------------------------- 队列

    fun setQueue(keys: List<String>, index: Int = 0) {
        state = state.copy(queue = keys, queueIndex = index.coerceIn(0, (keys.size - 1).coerceAtLeast(0)))
        persist()
    }

    fun setQueueIndex(i: Int) {
        state = state.copy(queueIndex = i)
        persist()
    }

    // --------------------------------------------------------------- 加密

    /**
     * 用 [Crypto] 把文件加密成 `.nfa`(AES-GCM),并登记成加密曲目。
     *
     * 为什么是「整文件加密成新文件」而不是「边读边解」:
     * ExoPlayer 要能随机 seek,边读边解必须实现一个自定义 DataSource,
     * 而 GCM 的认证标签在**尾部** —— 流式解密时你无法在开始播放前就知道
     * 后 16 字节有没有被改。所以这条路要做得对,得用「分块 + 每块独立标签」
     * 的格式。本轮先用整文件加密(适合有声书/离线包这类场景),
     * 并把这个限制写在界面上。
     */
    fun encryptTrack(key: String, secrets: cn.nfarms.core.secure.Secrets): Track? {
        val t = state.tracks.firstOrNull { it.key == key } ?: return null
        val src = File(t.path)
        if (!src.isFile) return null
        val out = File(mediaDir, src.nameWithoutExtension + ".nfa")
        val plain = src.readBytes()
        out.writeBytes(Crypto.seal(secrets.dataKey(), plain))
        val enc = t.copy(path = out.absolutePath, encrypted = true, key = t.key + "#enc")
        state = state.copy(tracks = state.tracks + enc)
        persist()
        return enc
    }

    /** 解密到临时文件,交给播放器。**只用于播放,不落明文到私有目录以外**。 */
    fun decryptToTemp(track: Track, secrets: cn.nfarms.core.secure.Secrets, tempDir: File): File? {
        if (!track.encrypted) return File(track.path).takeIf { it.isFile }
        val src = File(track.path)
        if (!src.isFile) return null
        val out = File(tempDir, src.nameWithoutExtension + ".dec")
        return runCatching {
            out.writeBytes(Crypto.open(secrets.dataKey(), src.readBytes()))
            out
        }.getOrNull()
    }

    // --------------------------------------------------------------- 其它

    fun audioDir(): File = mediaDir

    fun markSynced(at: Long) {
        state = state.copy(lastSyncAt = at)
        persist()
    }

    /** 曲目身份:内容指纹优先,退化为路径哈希。 */
    private fun keyOf(file: File): String {
        val base = "${file.length()}:${file.name}"
        return Crypto.sha256Hex(base).take(16)
    }

    private fun readSidecarLrc(file: File): String {
        val lrc = File(file.parentFile, file.nameWithoutExtension + ".lrc")
        return if (lrc.isFile) runCatching { lrc.readText() }.getOrDefault("") else ""
    }

    /** LRC 与内嵌歌词合并:有独立文件就用文件,否则用内嵌。 */
    fun lyricsOf(track: Track): List<LyricLine> {
        val path = File(track.path)
        val side = File(path.parentFile ?: File("/"), path.nameWithoutExtension + ".lrc")
        val text = if (side.isFile) runCatching { side.readText() }.getOrDefault("") else track.lyrics
        return Lrc.parse(text)
    }

    /** 序列化用 —— 让测试能直接比对落盘内容。 */
    fun dumpJson(): String = JsonStore.json().encodeToString(LibraryState.serializer(), state)

    @Suppress("unused")
    private fun unusedListSerializer() = ListSerializer(String.serializer())
}