Files
Thing/src/stores/musicUploadStore.ts
T
2026-09-15 17:17:16 +08:00

324 lines
13 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 { 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
}
})