Files
Thing/src/stores/musicStore.ts
T
2026-09-12 15:47:16 +08:00

616 lines
21 KiB
TypeScript
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.
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { commands, type MusicEnvStatus, type MusicSettings } from '@/lib/bindings'
import { useProxyStore } from '@/stores/proxyStore'
import { createLogger } from '@/lib/logger'
/** 搜索结果中的单曲(与 bridge.py song_to_dict 字段对应) */
export interface MusicSong {
songName: string | null
singers: string | null
album: string | null
duration: string | null
durationS: number | null
fileSize: string | null
fileSizeBytes: number | null
ext: string | null
source: string | null
rootSource: string | null
downloadUrl: string | null
valid: boolean
coverUrl: string | null
bitrate: number | null
/** 下载所需:save_path 命名用 */
identifier?: string | null
/** 默认下载请求头(rust 引擎直传下载器模块用) */
defaultDownloadHeaders?: Record<string, string> | null
defaultDownloadCookies?: Record<string, string> | null
/** 懒解析:官方搜索 API 的原始结果(resolve/下载前解析真实链接用) */
rawSearch?: Record<string, unknown> | null
/** 音质提示:true=具备无损(ext 或官方音质字段推断),null=未知;懒解析歌 ext/fileSize 为空时筛选依据 */
lossless?: boolean | null
/** 全音质档位(搜索阶段多档展示:label/ext/bitrate/sizeBytes/size/lossless);懒解析歌为数组,急切解析/无数据为 null */
qualities?: MusicQuality[] | null
}
/** 单个音质档位(musicdl 官方接口搜索阶段批量取回的格式+大小+无损) */
export interface MusicQuality {
label: string | null
ext: string | null
bitrate: number | null
sizeBytes: number | null
size: string | null
lossless: boolean | null
}
/** 桥接下载事件(bridge.py 事件行,经 Rust 原样转发) */
export interface MusicDownloadEvent {
event: string
taskId?: string
type?: 'start' | 'progress' | 'done' | 'error' | 'cancelled' | 'finished' | 'resolving' | 'bridge-stopped'
key?: string
songName?: string
singers?: string
ext?: string
/** 实际解析出的音质档位(start 事件回传,如 "无损"/"320K" */
quality?: string
downloaded?: number
total?: number
done?: number
message?: string
}
/** 任务内单曲状态(key = "{source}|{idx}" */
export interface MusicDownloadSongState {
key: string
songName: string
singers: string
status: 'queued' | 'resolving' | 'downloading' | 'done' | 'error' | 'cancelled'
downloaded: number
total: number
message?: string
/** 实际解析出的音质档位(start 事件回传);请求档位(如 "最高")在任务级 task.quality */
quality?: string
}
/** 音乐下载任务(musicdl 引擎) */
export interface MusicDownloadTask {
taskId: string
engine: 'musicdl' | 'rust'
status: 'downloading' | 'cancelling' | 'done' | 'error' | 'cancelled' | 'interrupted'
doneCount: number
songs: MusicDownloadSongState[]
/** 完整歌曲数据(localStorage 持久化,用于"重新下载" */
songsData: MusicSong[]
/** 下载目录(打开文件夹用) */
savedir?: string
/** 本次下载的目标音质 label("" 表示最高),用于任务标题展示 */
quality?: string
errorMessage?: string
createdAt: number
}
/** 运行时安装进度事件负载(事件类型非 specta 生成范围,本地定义) */
export interface MusicInstallProgress {
stage: string
percent: number
downloadedBytes: number
totalBytes: number | null
message: string
}
/** 搜索返回(bridge search 结果结构) */
export interface MusicSearchPayload {
results: Record<string, MusicSong[]>
total: number
sources: string[]
}
/** 桥接 ping 返回 */
export interface MusicPingPayload {
version: string
python: string
}
const logger = createLogger('music')
export const useMusicStore = defineStore('music', () => {
const settings = ref<MusicSettings | null>(null)
const env = ref<MusicEnvStatus | null>(null)
/** musicdl 已注册的全部搜索源(客户端名) */
const availableSources = ref<string[]>([])
// 搜索状态
const searching = ref(false)
const searchError = ref('')
const results = ref<Record<string, MusicSong[]>>({})
const resultTotal = ref(0)
/** 已执行过一次搜索/歌单解析(用于空结果时展示"未找到"而非初始引导空态) */
const searched = ref(false)
// 运行时安装状态
const installing = ref(false)
const installProgress = ref<MusicInstallProgress | null>(null)
const installError = ref('')
let progressUnlisten: UnlistenFn | null = null
async function init() {
restoreTasks()
await Promise.all([loadSettings(), refreshEnv()])
}
async function loadSettings() {
settings.value = await commands.musicGetSettings()
}
/** 保存设置(调用方先修改 settings 再调用;前端用 400ms debounce */
async function saveSettings() {
if (!settings.value) return
await commands.musicSaveSettings(settings.value)
}
async function refreshEnv() {
env.value = await commands.musicEnvStatus()
}
async function loadSources() {
try {
const v = (await invoke('music_get_sources')) as { sources: string[] }
availableSources.value = v.sources ?? []
} catch (e) {
logger.error(`加载音乐源失败: ${e}`)
availableSources.value = []
}
}
async function pingBridge(): Promise<MusicPingPayload> {
return (await invoke('music_ping')) as MusicPingPayload
}
async function search(keyword: string, sources: string[]) {
searching.value = true
searchError.value = ''
try {
const v = (await invoke('music_search', { keyword, sources })) as MusicSearchPayload
results.value = v.results ?? {}
resultTotal.value = v.total ?? 0
searched.value = true
} catch (e) {
searchError.value = typeof e === 'string' ? e : JSON.stringify(e)
throw e
} finally {
searching.value = false
}
}
// ===== 歌单解析 =====
const parsing = ref(false)
const parseError = ref('')
/** 解析歌单链接,结果以首个歌曲来源为键写入 results(与搜索共用展示/下载) */
async function parsePlaylist(url: string, sources: string[]) {
parsing.value = true
parseError.value = ''
try {
const v = (await invoke('music_parse_playlist', { url, sources })) as {
songs: MusicSong[]
count: number
}
const count = v.count ?? 0
searched.value = true
if (count > 0) {
const srcKey = v.songs[0]?.source ?? 'playlist'
results.value = { [srcKey]: v.songs }
resultTotal.value = count
} else {
results.value = {}
resultTotal.value = 0
}
return count
} catch (e) {
parseError.value = typeof e === 'string' ? e : JSON.stringify(e)
throw e
} finally {
parsing.value = false
}
}
// ===== 下载任务 =====
const tasks = ref<MusicDownloadTask[]>([])
let downloadUnlisten: UnlistenFn | null = null
// 任务历史持久化(localStorage,应用重启后保留记录;恢复时不尝试续传)
const TASKS_KEY = 'thing.music.tasks'
const MAX_TASKS = 30
/** 单次持久化的体积上限:超过则剥离 rawSearch(懒解析字段)后重试,避免撑爆 localStorage 配额 */
const MAX_PERSIST_CHARS = 1_500_000
let persistTimer: ReturnType<typeof setTimeout> | null = null
/** 剥离仅解析期使用的重字段(只影响「重新下载」对懒解析歌曲的能力) */
function stripHeavy(s: MusicSong): MusicSong {
const { rawSearch, defaultDownloadHeaders, defaultDownloadCookies, ...rest } = s
void rawSearch
void defaultDownloadHeaders
void defaultDownloadCookies
return rest
}
function persistTasks() {
const base = tasks.value.slice(0, MAX_TASKS)
try {
let payload = JSON.stringify(base)
if (payload.length > MAX_PERSIST_CHARS) {
payload = JSON.stringify(base.map((t) => ({ ...t, songsData: t.songsData.map(stripHeavy) })))
}
localStorage.setItem(TASKS_KEY, payload)
} catch {
// localStorage 不可用/超限时静默降级(仅影响历史记录)
}
}
function restoreTasks() {
try {
const raw = localStorage.getItem(TASKS_KEY)
if (!raw) return
const arr = JSON.parse(raw) as MusicDownloadTask[]
if (!Array.isArray(arr)) return
for (const t of arr) {
// 重启后桥接已无下载状态:残留的"下载中"统一标记为中断;
// "取消中"表明用户已请求取消,落定为已取消
if (t.status === 'downloading') t.status = 'interrupted'
else if (t.status === 'cancelling') t.status = 'cancelled'
if (!Array.isArray(t.songs)) t.songs = []
if (!Array.isArray(t.songsData)) t.songsData = []
}
tasks.value = arr.slice(0, MAX_TASKS)
} catch {
// 数据损坏则丢弃历史
}
}
// 仅在结构性变化(任务增删 / 任务状态 / 完成数)时持久化:
// 用轻量签名代替深度 watch,避免下载进度事件(每 300ms 修改 downloaded
// 触发 30 个任务(含 songsData)的全量 JSON 序列化
const tasksSignature = computed(
() => tasks.value.map((t) => `${t.taskId}:${t.status}:${t.doneCount}`).join('|')
)
watch(
tasksSignature,
() => {
if (persistTimer) clearTimeout(persistTimer)
persistTimer = setTimeout(persistTasks, 400)
}
)
async function ensureDownloadListener() {
if (downloadUnlisten) return
downloadUnlisten = await listen<MusicDownloadEvent>('music-download-event', (e) => {
handleDownloadEvent(e.payload)
})
}
function handleDownloadEvent(ev: MusicDownloadEvent) {
if (ev.type === 'bridge-stopped') {
// 桥接进程被停止:活动任务标记为中断;取消中的任务落定为已取消
for (const t of tasks.value) {
if (t.status === 'downloading') t.status = 'interrupted'
else if (t.status === 'cancelling') t.status = 'cancelled'
}
return
}
if (!ev.taskId) return
const task = tasks.value.find((t) => t.taskId === ev.taskId)
if (!task) return
const type = ev.type
if (type === 'resolving') {
// 懒解析:下载 worker 正在解析真实下载链接(第三方 API + 官方音质阶梯)
const st = task.songs.find((s) => s.key === ev.key)
if (st) {
st.status = 'resolving'
st.songName = ev.songName ?? st.songName
st.singers = ev.singers ?? st.singers
}
if (task.status !== 'cancelling') task.status = 'downloading'
} else if (type === 'start') {
const st = task.songs.find((s) => s.key === ev.key)
if (st) {
st.status = 'downloading'
st.songName = ev.songName ?? st.songName
st.singers = ev.singers ?? st.singers
st.total = ev.total ?? st.total
st.quality = ev.quality ?? st.quality
}
// 取消请求后桥接仍可能推送在途歌曲的 start 事件,不覆盖「取消中」状态
if (task.status !== 'cancelling') task.status = 'downloading'
} else if (type === 'progress') {
const st = task.songs.find((s) => s.key === ev.key)
if (st) {
st.downloaded = ev.downloaded ?? 0
st.total = ev.total ?? st.total
}
} else if (type === 'done') {
const st = task.songs.find((s) => s.key === ev.key)
if (st) {
// 幂等:重复的 done 事件(断点续传/重发)不再累加 doneCount
if (st.status !== 'done') task.doneCount++
st.status = 'done'
st.downloaded = st.total || st.downloaded
}
} else if (type === 'error') {
const st = task.songs.find((s) => s.key === ev.key)
if (st) {
st.status = 'error'
st.message = ev.message
} else if (ev.key === undefined) {
// 任务级错误(worker 初始化失败/监督线程异常):桥接不会再发 finished,
// 此处直接落定最终状态,避免任务永久停留在「下载中」
task.errorMessage = ev.message
task.status = task.status === 'cancelling' ? 'cancelled' : 'error'
}
} else if (type === 'cancelled') {
const st = task.songs.find((s) => s.key === ev.key)
if (st) st.status = 'cancelled'
} else if (type === 'finished') {
const anyError = task.songs.some((s) => s.status === 'error')
const anyCancelled = task.songs.some((s) => s.status === 'cancelled')
task.status = anyError ? 'error' : anyCancelled ? 'cancelled' : 'done'
}
}
/** 清洗文件名中的 Windows 非法字符(\ / : * ? " < > | 及控制字符),并去掉结尾空格/点 */
function sanitizeFilename(name: string): string {
return (
name
.replace(/[\\/:*?"<>|\x00-\x1f]/g, '')
.replace(/\s+/g, ' ')
.trim()
.replace(/[. ]+$/, '') || 'music'
)
}
/** 懒解析:解析单首歌曲的真实下载链接(试听/上传前调用),成功后原地更新
* 搜索结果并返回带链接的最新歌曲对象;失败返回 null。
* `quality` 为音质档位 label"" = 最高),解析时按「≤所选最优档」封顶。 */
async function resolveSong(source: string, index: number, quality = ''): Promise<MusicSong | null> {
const song = results.value[source]?.[index]
if (!song) return null
if (song.downloadUrl) return song
if (!song.rawSearch) return null
const v = (await invoke('music_resolve', { song, quality })) as {
songs: (MusicSong | null)[]
}
const resolved = v.songs?.[0]
if (!resolved?.downloadUrl) return null
// 原地更新(索引未变,key 稳定);保留 rawSearch 供后续重新解析
if (results.value[source]?.[index] === song) {
results.value[source][index] = { ...song, ...resolved }
}
return resolved
}
/** 启动下载。engine='rust' 时交给下载器模块(任务在其列表中管理);
* 否则走 musicdl 桥接。返回实际使用的引擎与跳过的无链接歌曲数 */
async function startDownload(
songs: MusicSong[],
opts: {
savedir: string
lyric: boolean
cover: boolean
proxyUrl: string
engine: string
maxConcurrent: number
/** 目标音质 label"" 表示最高);解析下载时按「≤所选最优档」封顶 */
quality?: string
}
): Promise<{ engine: 'musicdl' | 'rust'; skipped: number; taskId?: string }> {
if (songs.length === 0) return { engine: 'musicdl', skipped: 0 }
await ensureDownloadListener()
const quality = opts.quality ?? ''
if (opts.engine === 'rust') {
// 懒解析歌曲先批量解析出真实链接(桥接内并行),再交给下载器模块
const lazy = songs.filter((s) => !s.downloadUrl && s.rawSearch)
if (lazy.length > 0) {
try {
const v = (await invoke('music_resolve', { songs: lazy, quality })) as {
songs: (MusicSong | null)[]
}
// 返回与输入顺序对齐,失败位为 null
v.songs?.forEach((r, i) => {
if (r && lazy[i]) Object.assign(lazy[i], r)
})
} catch (e) {
logger.error(`批量解析下载链接失败: ${e}`)
}
}
let skipped = 0
for (const song of songs) {
if (!song.downloadUrl) {
skipped++
continue
}
const ext = (song.ext ?? '').replace(/[\\/:*?"<>|\x00-\x1f]/g, '')
const raw = `${song.songName ?? 'music'}${ext ? '.' + ext : ''}`
try {
await commands.downloaderAddTask(
song.downloadUrl,
sanitizeFilename(raw),
opts.savedir,
song.defaultDownloadHeaders ?? null,
true,
null
)
} catch (e) {
logger.error(`下载失败 ${song.songName}: ${e}`)
throw e
}
}
return { engine: 'rust', skipped }
}
const taskId = crypto.randomUUID()
const task: MusicDownloadTask = {
taskId,
engine: 'musicdl',
status: 'downloading',
doneCount: 0,
createdAt: Date.now(),
songsData: songs,
savedir: opts.savedir,
quality,
songs: songs.map((s, idx) => ({
key: `${s.source ?? ''}|${idx}`,
songName: s.songName ?? '',
singers: s.singers ?? '',
status: 'queued' as const,
downloaded: 0,
total: s.fileSizeBytes ?? 0
}))
}
tasks.value.unshift(task)
if (tasks.value.length > 30) tasks.value.length = 30
try {
await invoke('music_download', {
taskId,
// 有 rawSearch 时丢弃搜索阶段的 downloadUrl(CDN 链接有时效,
// 交给桥接在下载 worker 里重新解析,顺带修复"隔夜链接过期"问题)
songs: songs.map((s) => (s.rawSearch ? { ...s, downloadUrl: null } : s)),
savedir: opts.savedir,
lyric: opts.lyric,
cover: opts.cover,
proxyUrl: opts.proxyUrl,
maxConcurrent: opts.maxConcurrent,
quality
})
} catch (e) {
task.status = 'error'
task.errorMessage = String(e)
throw e
}
return { engine: 'musicdl', skipped: 0, taskId }
}
async function cancelDownload(taskId: string) {
const task = tasks.value.find((t) => t.taskId === taskId)
if (!task || (task.status !== 'downloading' && task.status !== 'cancelling')) return
// 队列级取消:当前歌曲会完成,其余标记取消;最终状态由 finished 事件落定,
// 此处先置「取消中」防止用户在剩余歌曲停止前误点「重新下载」产生重复任务
task.status = 'cancelling'
try {
await invoke('music_download_cancel', { taskId })
} catch {
// 桥接不可用(进程已死,无后台下载)→ 直接落定为已取消,避免卡在取消中
if (task.status === 'cancelling') task.status = 'cancelled'
}
}
function removeTask(taskId: string) {
// 下载中/取消中的任务先请求队列级取消,防止移除后后台仍在下载
const task = tasks.value.find((t) => t.taskId === taskId)
if (task && (task.status === 'downloading' || task.status === 'cancelling')) {
invoke('music_download_cancel', { taskId }).catch(() => {})
}
tasks.value = tasks.value.filter((t) => t.taskId !== taskId)
}
/** 重新下载:用任务内保存的完整歌曲数据重新发起(error/cancelled/interrupted 状态可用) */
async function redownloadTask(taskId: string) {
const task = tasks.value.find((t) => t.taskId === taskId)
if (
!task ||
task.status === 'downloading' ||
task.status === 'cancelling' ||
task.songsData.length === 0
) {
return
}
const s = settings.value
if (!s) return
// 代理 URL:惰性读取代理模块设置(避免 store 初始化时跨 store 依赖)
let proxy = ''
if (s.useProxy) {
const port = useProxyStore().settings?.mixedPort
proxy = port ? `http://127.0.0.1:${port}` : ''
}
await startDownload(task.songsData, {
savedir: s.savedir,
lyric: s.lyricDownload,
cover: s.coverDownload,
proxyUrl: proxy,
engine: s.downloadEngine,
maxConcurrent: s.maxConcurrent,
// 沿用原任务的目标音质("最高" 存为空串),而不是当前默认音质
quality: task.quality ?? s.defaultDownloadQuality ?? ''
})
}
async function installRuntime() {
if (installing.value) return
installing.value = true
installError.value = ''
installProgress.value = null
if (!progressUnlisten) {
progressUnlisten = await listen<MusicInstallProgress>(
'music-runtime-install-progress',
(e) => {
installProgress.value = e.payload
}
)
}
try {
env.value = await commands.musicInstallRuntime()
} catch (e) {
// 保留失败原因供设置页面板展示;进度停在最后事件值,需清掉避免误导
installError.value = typeof e === 'string' ? e : JSON.stringify(e)
throw e
} finally {
installing.value = false
if (progressUnlisten) {
progressUnlisten()
progressUnlisten = null
}
}
}
function cancelInstall() {
commands.musicCancelRuntimeInstall().catch(() => {})
}
async function stopBridge() {
await commands.musicStopBridge()
}
return {
settings,
env,
availableSources,
searching,
searchError,
searched,
parsing,
parseError,
results,
resultTotal,
installing,
installProgress,
installError,
tasks,
init,
loadSettings,
saveSettings,
refreshEnv,
loadSources,
pingBridge,
search,
parsePlaylist,
resolveSong,
startDownload,
cancelDownload,
removeTask,
redownloadTask,
installRuntime,
cancelInstall,
stopBridge
}
})