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 | null defaultDownloadCookies?: Record | null /** 懒解析:官方搜索 API 的原始结果(resolve/下载前解析真实链接用) */ rawSearch?: Record | 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 total: number sources: string[] } /** 桥接 ping 返回 */ export interface MusicPingPayload { version: string python: string } const logger = createLogger('music') export const useMusicStore = defineStore('music', () => { const settings = ref(null) const env = ref(null) /** musicdl 已注册的全部搜索源(客户端名) */ const availableSources = ref([]) // 搜索状态 const searching = ref(false) const searchError = ref('') const results = ref>({}) const resultTotal = ref(0) /** 已执行过一次搜索/歌单解析(用于空结果时展示"未找到"而非初始引导空态) */ const searched = ref(false) // 运行时安装状态 const installing = ref(false) const installProgress = ref(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 { 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([]) 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 | 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('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 { 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( '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 } })