import { defineStore } from 'pinia' import { ref, watch } from 'vue' import { toast } from 'vue-sonner' import { invoke } from '@tauri-apps/api/core' import { useMusicStore, type MusicDownloadTask, type MusicSong } from '@/stores/musicStore' import { useFeiniuStore } from '@/stores/feiniuStore' import { createLogger } from '@/lib/logger' const logger = createLogger('music-upload') /** feiniu_scan_local 返回的本地音频条目(只取用到的字段) */ interface LocalAudioFile { path: string /** 文件名(不含扩展名) */ name?: string /** 音频标签里的标题 */ title?: string /** 修改时间(Unix 秒) */ mtim?: number } /** * 下载 → 上传飞牛的记账中枢。 * * 为什么必须是一个 **store** 而不是组件内逻辑: * 这套记账是「任务完成时触发上传」的**唯一触发点**,一旦放在 `MusicModule.vue` * 的 setup 里,用户下载途中切走音乐模块(组件卸载 → watcher 被销毁)就永远不会触发—— * 表现为「下载到飞牛」下载完了却没上传、本地副本也没删,自动上传同样静默失效。 * Pinia 的 setup store 活在独立 effect scope 中,watcher 不随组件卸载而销毁。 * * 记账粒度到**任务**(而非「最近 N 分钟的文件」): * 任务行上的「上传到飞牛曲库」只应传它自己下载的文件, * 否则会把别的任务、甚至用户手动放进下载目录的文件一起传上去。 * * 所有上传走**同一条串行队列**:`feiniu_scan_local` 要遍历整个曲库目录做标签解析, * 并发跑两个上传等于并发扫两遍盘,还会让 `uploading` 标志提前归位(按钮状态错乱)。 */ export const useMusicUploadStore = defineStore('musicUpload', () => { const music = useMusicStore() const feiniu = useFeiniuStore() /** 行级「下载到飞牛」进行中的 key(`${source}|${index}`),驱动行内 spinner */ const rowUploadingKeys = ref>(new Set()) /** 已上传过的任务 id:每个任务最多上传一次,避免重复全量上传 */ const handledTaskIds = new Set() /** 「下载到飞牛」的临时任务:完成后上传 → 删本地副本 → 移除记录 */ const tempTaskIds = new Set() /** taskId → 行按钮 spinner 的 key */ const tempTaskKeys = new Map() /** 下载目标选「飞牛」的 musicdl 任务:完成后自动上传 */ const uploadOnDoneTaskIds = new Set() /** * 初始化完成前不触发自动上传。 * 由 UI 在 `music.init()`(恢复历史任务)之后调用 `armAfterInit()` 置真, * 否则启动时会拿着上一轮已完成的任务重传一遍。 */ let armed = false function setRowUploading(key: string, on: boolean) { const next = new Set(rowUploadingKeys.value) if (on) next.add(key) else next.delete(key) rowUploadingKeys.value = next } function releaseRowKey(taskId: string) { const key = tempTaskKeys.get(taskId) tempTaskKeys.delete(taskId) if (key) setRowUploading(key, false) } // ===== 上传串行队列 ===== const uploadQueue: Array<() => Promise> = [] let draining = false /** * 入队一个上传作业并保证串行执行。 * 队列排空后再扫一遍任务列表:上传期间新完成的任务在这一轮被接上, * 因此不需要「跳过并等下一次状态变化」这种会漏单的写法。 */ function enqueueUpload(job: () => Promise) { uploadQueue.push(job) if (draining) return draining = true void (async () => { try { while (uploadQueue.length > 0) { const next = uploadQueue.shift() if (!next) break await next().catch((e) => { logger.error(`上传作业失败: ${e}`) toast.error(`上传到飞牛失败:${e}`) }) } } finally { draining = false } resolvePendingUploads() })() } /** * 上传某个下载任务产出的音频文件。 * * 匹配策略(从精确到宽松): * 1. 文件名或音频标签标题命中任务内任一歌曲名; * 2. 文件名以「歌名 - 」开头也算命中——`bridge.py::_rename_downloaded_files` * 在下载完成后会把落盘文件从「歌名 - <标识>.ext」改名成「歌名 - 歌手.ext」; * 3. 都不命中时退回「修改时间 ≥ 任务创建时刻」。 * 所有规则都限定在任务时间之后,因此不会带上历史文件。 */ async function uploadTaskFiles( task: MusicDownloadTask, deleteLocal: boolean ): Promise<{ total: number; ok: number; deleted: number }> { if (!feiniu.webdavReady) throw new Error('请先在「设置 → 存储与上传」完成 WebDAV 配置') // 同步置位:UI 的「上传中」禁用态依赖它 feiniu.uploading = true try { // 只列举下载目录(非递归,不解析标签):musicdl 的落盘位置恒为 savedir // (bridge.py `si.work_dir = savedir`)。用 feiniu_scan_local 会递归遍历整个 // 曲库目录并解析标签——为一次上传扫全库是纯浪费。 const dir = task.savedir || music.settings?.savedir || '' if (!dir) throw new Error('未配置下载目录,无法定位下载产物') const r = await invoke<{ items: LocalAudioFile[] }>('feiniu_list_audio_files', { dir }) const items = r?.items ?? [] // 任务创建时刻(毫秒)→ 秒,留 5s 余量吸收文件系统时间戳抖动 const since = task.createdAt / 1000 - 5 const recent = items.filter((f) => (f.mtim || 0) >= since) const names = [ ...new Set(task.songsData.map((s) => (s.songName ?? '').trim().toLowerCase()).filter(Boolean)) ] const nameSet = new Set(names) const byName = names.length > 0 ? recent.filter((f) => { const stem = String(f.name ?? '').trim().toLowerCase() const title = String(f.title ?? '').trim().toLowerCase() if (nameSet.has(stem) || nameSet.has(title)) return true return names.some((n) => stem.startsWith(`${n} - `)) }) : [] const list = byName.length > 0 ? byName : recent let ok = 0 let deleted = 0 for (const f of list) { const path = String(f.path) const name = path.split(/[\\/]/).pop() || 'music.bin' try { await feiniu.uploadToFeiniu(path, name) ok++ if (deleteLocal) deleted += await feiniu.deleteLocalMedia(path) } catch (e) { logger.error(`上传失败 ${name}: ${e}`) } } return { total: list.length, ok, deleted } } finally { feiniu.uploading = false } } /** 任务行上的「上传到飞牛曲库」:只传该任务下载的文件 */ function uploadTask(taskId: string) { const task = music.tasks.find((t) => t.taskId === taskId) if (!task) return if (!feiniu.webdavReady) { toast.error('请先在「设置 → 存储与上传」完成 WebDAV 配置(地址 / 账号 / 密码 / 目标目录)') return } enqueueUpload(async () => { const { total, ok } = await uploadTaskFiles(task, false) if (total === 0) toast.info('未找到该任务下载的文件(可能已被移动或删除)') else toast.success(`已上传 ${ok}/${total} 首到飞牛曲库`) }) } /** 临时任务的收尾:上传 → 删本地副本 → 移除任务记录。失败时保留本地文件与记录供排查。 */ async function finishTempUpload(taskId: string) { const task = music.tasks.find((t) => t.taskId === taskId) try { if (!task) throw new Error('任务记录已不存在') const { total, ok, deleted } = await uploadTaskFiles(task, true) const name = task.songs?.[0]?.songName || '' if (ok > 0) { toast.success(`已保存到飞牛曲库${name ? `:${name}` : ''}`) music.removeTask(taskId) if (deleted < total) toast.info(`${total - deleted} 个本地副本未能删除(可能被占用)`) } else if (total === 0) { toast.error('下载完成,但未在下载目录找到新文件——请检查下载目录设置') } else { throw new Error('上传失败') } } finally { releaseRowKey(taskId) } } /** * 单曲「下载到飞牛」:走 musicdl 下载(含引擎侧解析、代理、音质), * 任务完成后上传并删除本地副本,最后移除任务记录。 */ async function downloadToFeiniu(song: MusicSong, source: string, index: number) { const key = `${source}|${index}` if (rowUploadingKeys.value.has(key)) return if (!feiniu.webdavReady) { toast.error('请先在「设置 → 存储与上传」完成 WebDAV 配置(地址 / 账号 / 密码 / 目标目录)') return } const s = music.settings if (!s) return // Rust 引擎的任务在「下载器」模块管理,本模块拿不到落盘结果 if (s.downloadEngine === 'rust') { toast.error( 'Rust 引擎的下载任务在「下载器」模块中管理,无法自动回传飞牛曲库。请改用 musicdl 引擎。' ) return } setRowUploading(key, true) try { const { engine, skipped, taskId } = await music.startDownload([song], { savedir: s.savedir, lyric: s.lyricDownload, cover: s.coverDownload, proxyUrl: s.useProxy ? music.currentProxyUrl() : '', engine: s.downloadEngine, maxConcurrent: s.maxConcurrent, quality: s.defaultDownloadQuality ?? '' }) if (engine === 'rust' || skipped > 0) { toast.error(skipped > 0 ? '该歌曲没有可用的下载源' : 'Rust 引擎无法回传飞牛曲库') setRowUploading(key, false) return } if (taskId) { tempTaskIds.add(taskId) tempTaskKeys.set(taskId, key) } else { setRowUploading(key, false) } } catch (e) { toast.error(`启动下载失败:${e}`) setRowUploading(key, false) } } /** 标记「该任务完成后自动上传」(下载目标选了飞牛时调用) */ function markUploadOnDone(taskId: string) { uploadOnDoneTaskIds.add(taskId) } /** 由 UI 在 music.init() 之后调用一次:把历史已完成任务视为已处理,然后开始接收新任务 */ function armAfterInit() { if (armed) return for (const t of music.tasks) if (t.status === 'done') handledTaskIds.add(t.taskId) armed = true } /** * 任务状态变化的唯一驱动: * - 临时任务(下载到飞牛)完成 → 上传 + 删本地副本 + 移除记录; * - 普通任务完成且要求自动上传 → 上传该任务的文件; * - 临时任务失败/被取消/被移除 → 释放行内 spinner 并提示。 * * 只负责「发现作业并入队」,并发控制交给串行队列。 */ function resolvePendingUploads() { if (!armed) return const alive = new Set(music.tasks.map((t) => t.taskId)) for (const id of [...handledTaskIds]) if (!alive.has(id)) handledTaskIds.delete(id) for (const t of music.tasks) { if (t.status !== 'done') continue if (tempTaskIds.has(t.taskId)) { // 先摘标记再入队:队列可能同步执行,重复入队会导致同一任务上传两次 tempTaskIds.delete(t.taskId) const id = t.taskId enqueueUpload(() => finishTempUpload(id)) continue } const wanted = uploadOnDoneTaskIds.has(t.taskId) || feiniu.autoUpload if (!wanted || handledTaskIds.has(t.taskId)) continue handledTaskIds.add(t.taskId) uploadOnDoneTaskIds.delete(t.taskId) const task = t enqueueUpload(async () => { const { total, ok } = await uploadTaskFiles(task, false) if (total > 0 && ok > 0) toast.success(`已上传 ${ok}/${total} 首到飞牛曲库`) }) } // 临时任务下载失败/被取消:释放行按钮 spinner 与临时标记,避免永久转圈 for (const t of music.tasks) { if (t.status === 'done' || t.status === 'downloading' || t.status === 'cancelling') continue if (!tempTaskIds.has(t.taskId)) continue tempTaskIds.delete(t.taskId) releaseRowKey(t.taskId) toast.error(`下载到飞牛失败:${t.errorMessage || '任务未完成,文件保留在本地下载目录'}`) } // 临时任务记录被手动删除(下载中移除):同样释放 spinner for (const id of [...tempTaskIds]) { if (alive.has(id)) continue tempTaskIds.delete(id) releaseRowKey(id) } } watch( () => music.tasks.map((t) => `${t.taskId}:${t.status}`).join('|'), resolvePendingUploads ) return { rowUploadingKeys, uploadTask, uploadTaskFiles, downloadToFeiniu, markUploadOnDone, armAfterInit } })