音乐模块调整

This commit is contained in:
zhongluofeng
2026-09-15 17:17:16 +08:00
parent 4f574cb5fa
commit 8f853f7ef3
31 changed files with 3819 additions and 936 deletions
+323
View File
@@ -0,0 +1,323 @@
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<Set<string>>(new Set())
/** 已上传过的任务 id:每个任务最多上传一次,避免重复全量上传 */
const handledTaskIds = new Set<string>()
/** 「下载到飞牛」的临时任务:完成后上传 → 删本地副本 → 移除记录 */
const tempTaskIds = new Set<string>()
/** taskId → 行按钮 spinner 的 key */
const tempTaskKeys = new Map<string, string>()
/** 下载目标选「飞牛」的 musicdl 任务:完成后自动上传 */
const uploadOnDoneTaskIds = new Set<string>()
/**
* 初始化完成前不触发自动上传。
* 由 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<void>> = []
let draining = false
/**
* 入队一个上传作业并保证串行执行。
* 队列排空后再扫一遍任务列表:上传期间新完成的任务在这一轮被接上,
* 因此不需要「跳过并等下一次状态变化」这种会漏单的写法。
*/
function enqueueUpload(job: () => Promise<void>) {
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
}
})