Files
LoveLiveMusicPlayerKMP/shared/src/commonMain/kotlin/com/zhushenwudi/lovelivemusicplayer/util/PathResolver.kt
T

175 lines
6.4 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.zhushenwudi.lovelivemusicplayer.util
import io.ktor.http.encodeURLPathPart
import com.zhushenwudi.lovelivemusicplayer.domain.Album
import com.zhushenwudi.lovelivemusicplayer.domain.Music
import com.zhushenwudi.lovelivemusicplayer.lyric.PlatformInfo
import com.zhushenwudi.lovelivemusicplayer.platform.AppLog
import com.zhushenwudi.lovelivemusicplayer.platform.FileSystem
import com.zhushenwudi.lovelivemusicplayer.settings.AppSettings
/**
* 将歌曲的目录和文件相对路径合并成专辑可独立使用的完整相对路径。
* 已经是完整路径时不重复拼接,兼容云端导入和旧数据库数据。
*/
internal fun joinMediaRelativePath(baseUrl: String?, path: String?): String? {
val child = path?.trim()?.trim('/', '\\').orEmpty()
if (child.isEmpty()) return null
val base = baseUrl?.trim()?.trim('/', '\\').orEmpty()
if (base.isEmpty()) return child
if (child == base || child.startsWith("$base/")) return child
return "$base/$child"
}
class PathResolver(
private val fs: FileSystem,
private val settings: AppSettings,
) {
/**
* 存储根路径。
* iOS Documents UUID 重装后会变,iOS 上不持久化 SP_SD_PATH;
* iOS 上始终使用 `FileSystem.musicRoot`。
*/
fun storageRoot(): String {
if (PlatformInfo.isIos) return fs.musicRoot
val sd = settings.sdPath.trim()
if (sd.isNotEmpty()) {
return sd.trimEnd('/', '\\') + "/"
}
return fs.musicRoot
}
fun absoluteMusicPath(music: Music): String? {
val base = music.baseUrl ?: return null
// iOS 本地路径存为 .wav;也兼容数据库中仍为 .flac 的行。
val path = AudioPathUtils.flac2wav(music.musicPath) ?: return null
return storageRoot() + base.trimStart('/') + path.trimStart('/')
}
/**
* 探测本地音频文件的绝对路径候选(iOS:优先 wav,回退 flac)。
*/
fun absoluteMusicPathCandidates(music: Music): List<String> {
val base = music.baseUrl ?: return emptyList()
val raw = music.musicPath?.trim().orEmpty()
if (raw.isEmpty()) return emptyList()
val root = storageRoot() + base.trimStart('/')
val primary = AudioPathUtils.flac2wav(raw) ?: raw
val candidates = linkedSetOf(root + primary.trimStart('/'))
if (PlatformInfo.isIos) {
val flac = AudioPathUtils.wav2flac(raw) ?: raw
if (flac != primary) {
candidates += root + flac.trimStart('/')
}
if (raw.endsWith(".flac", ignoreCase = true)) {
candidates += root + raw.trimStart('/')
}
}
return candidates.toList()
}
fun absoluteCoverPath(music: Music): String? {
val relative = joinMediaRelativePath(music.baseUrl, music.coverPath) ?: return null
return storageRoot() + relative
}
fun absoluteAlbumCoverPath(album: Album): String? {
val path = album.coverPath ?: return null
return storageRoot() + path.trimStart('/')
}
/**
* 拼接 HTTP 根路径与相对路径,逐段编码。
* 部分主机要求编码空格/Unicode(如 `μ's`、中文、`[]`)。
*/
fun joinHttpUrl(vararg relativeParts: String): String? {
val http = settings.httpUrl.trim()
if (http.isBlank()) return null
val rel = relativeParts.joinToString("").trimStart('/')
if (rel.isBlank()) return null
val encoded = rel.split('/')
.filter { it.isNotEmpty() }
.joinToString("/") { it.encodeURLPathPart() }
return http.trimEnd('/') + "/" + encoded
}
/**
* 歌曲封面 URI:本地 file://、HTTP URL,或 null(UI 显示默认 Logo)。
*/
fun coverDisplayUri(music: Music): String? {
if (music.musicId == null) return null
if (music.existFile == true) {
val path = absoluteCoverPath(music)
if (path != null && fs.exists(path)) {
return "file://$path"
}
}
if (settings.enableHttp) {
val relative = joinMediaRelativePath(music.baseUrl, music.coverPath) ?: return null
return joinHttpUrl(relative)
}
return null
}
/** 专辑封面 URI。 */
fun coverDisplayUri(album: Album): String? {
val rel = album.coverPath ?: return null
if (album.existFile == true) {
val path = absoluteAlbumCoverPath(album)
if (path != null && fs.exists(path)) {
return "file://$path"
}
}
if (settings.enableHttp) {
return joinHttpUrl(rel)
}
return null
}
fun playbackUri(music: Music): String? {
val id = music.musicId
val enableHttp = settings.enableHttp
val httpBlank = settings.httpUrl.isBlank()
if (enableHttp && !httpBlank) {
val base = music.baseUrl.orEmpty()
// HTTP 曲库为 flac;iOS 本地导入后数据库可能存 wav。
val path = AudioPathUtils.wav2flac(music.musicPath).orEmpty()
val joined = joinHttpUrl(base, path)
if (joined == null) {
AppLog.w(PLAY_TAG, "playbackUri http joinNull id=$id")
}
return joined
}
val candidates = absoluteMusicPathCandidates(music)
val local = candidates.firstOrNull { fs.exists(it) }
if (local != null) {
return "file://$local"
}
val fallback = music.musicPath?.takeIf {
it.startsWith("http") || it.startsWith("asset")
}
if (fallback == null) {
AppLog.w(
PLAY_TAG,
"playbackUri null id=$id enableHttp=$enableHttp httpBlank=$httpBlank " +
"candidates=$candidates root=${storageRoot()}",
)
}
return fallback
}
fun refreshExistFile(music: Music): Music {
val exists = absoluteMusicPathCandidates(music).any { fs.exists(it) }
// 磁盘存在时,将 iOS 本地扩展名持久化到数据库(flac→wav)。
val pathForDb = when {
!PlatformInfo.isIos -> music.musicPath
else -> AudioPathUtils.flac2wav(music.musicPath)
}
return music.copy(musicPath = pathForDb, existFile = exists)
}
private companion object {
const val PLAY_TAG = "LLMP-Play"
}
}