音乐模块调整
This commit is contained in:
@@ -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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user