调整,音乐模块
This commit is contained in:
@@ -0,0 +1,799 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const logger = createLogger('feiniu')
|
||||
|
||||
/** 可播条目(飞牛 NAS 或本地文件),是播放器/歌单的统一数据模型 */
|
||||
export interface PlayableItem {
|
||||
source: 'feiniu' | 'local'
|
||||
/** feiniu 源:track guid;local 源:文件路径 */
|
||||
guid?: string
|
||||
title: string
|
||||
artistNames: string
|
||||
album?: string
|
||||
durationMs?: number
|
||||
coverId?: string
|
||||
/** local 源文件大小(字节) */
|
||||
size?: number
|
||||
/** local 源所在目录 */
|
||||
dir?: string
|
||||
}
|
||||
|
||||
export interface FeiniuConnection {
|
||||
id: string
|
||||
name: string
|
||||
kind: 'lan' | 'frp' | 'fnconnect'
|
||||
baseUrl: string
|
||||
username: string
|
||||
loggedIn: boolean
|
||||
accessCode: string
|
||||
insecure: boolean
|
||||
fnId?: string
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
id: string
|
||||
name: string
|
||||
items: PlayableItem[]
|
||||
}
|
||||
|
||||
export type PlayMode = 'loopAll' | 'loopOne' | 'shuffle'
|
||||
|
||||
interface LrcLine {
|
||||
t: number
|
||||
text: string
|
||||
}
|
||||
|
||||
const PLAYERS_KEY = 'thing.music.playlists'
|
||||
const QUEUE_KEY = 'thing.music.queue'
|
||||
const VOL_KEY = 'thing.music.volume'
|
||||
|
||||
function uid(): string {
|
||||
return `p${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`
|
||||
}
|
||||
|
||||
export const useFeiniuStore = defineStore('feiniu', () => {
|
||||
// ===== 连接 =====
|
||||
const connections = ref<FeiniuConnection[]>([])
|
||||
const activeId = ref('')
|
||||
const config = ref<{ baseUrl: string; username: string; loggedIn: boolean }>({
|
||||
baseUrl: '',
|
||||
username: '',
|
||||
loggedIn: false
|
||||
})
|
||||
const mediaPrefix = ref('')
|
||||
const connecting = ref(false)
|
||||
// 缓存播放模式('stream' 直连 | 'cache' 缓存后播放)
|
||||
const cacheMode = ref<'stream' | 'cache'>('stream')
|
||||
const cacheStatus = ref<{ count: number; usedMb: number }>({ count: 0, usedMb: 0 })
|
||||
|
||||
// fnOS 文件服务(P6:上传到飞牛)
|
||||
const fnosLoggedIn = ref(false)
|
||||
const libraryNasPath = ref('')
|
||||
const autoUpload = ref(false)
|
||||
const uploading = ref(false)
|
||||
|
||||
const activeConn = computed(() => connections.value.find((c) => c.id === activeId.value) || null)
|
||||
|
||||
// ===== 曲库(飞牛 + 本地) =====
|
||||
const tracks = ref<PlayableItem[]>([])
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
const localTracks = ref<PlayableItem[]>([])
|
||||
const localScanBusy = ref(false)
|
||||
|
||||
// ===== 歌单 =====
|
||||
const playlists = ref<Playlist[]>([])
|
||||
|
||||
// ===== 播放器内核 =====
|
||||
const queue = ref<PlayableItem[]>([])
|
||||
const queueIndex = ref(-1)
|
||||
const playMode = ref<PlayMode>('loopAll')
|
||||
const volume = ref(0.8)
|
||||
const current = computed(() => queue.value[queueIndex.value] ?? null)
|
||||
const playing = ref(false)
|
||||
const loadingPlay = ref(false)
|
||||
const position = ref(0)
|
||||
const duration = ref(0)
|
||||
const lyricLines = ref<LrcLine[]>([])
|
||||
const lyricVisible = ref(false)
|
||||
const queueVisible = ref(false)
|
||||
const nowPlayingOpen = ref(false)
|
||||
let lyricRaw = ''
|
||||
let audioEl: HTMLAudioElement | null = null
|
||||
|
||||
function ensureAudio(): HTMLAudioElement {
|
||||
if (!audioEl) {
|
||||
const a = new Audio()
|
||||
a.preload = 'auto'
|
||||
a.volume = volume.value
|
||||
a.addEventListener('loadedmetadata', () => (duration.value = a.duration || 0))
|
||||
a.addEventListener('timeupdate', () => (position.value = a.currentTime))
|
||||
a.addEventListener('play', () => (playing.value = true))
|
||||
a.addEventListener('pause', () => (playing.value = false))
|
||||
a.addEventListener('ended', () => onEnded())
|
||||
a.addEventListener('error', () => {
|
||||
loadingPlay.value = false
|
||||
playing.value = false
|
||||
})
|
||||
audioEl = a
|
||||
}
|
||||
return audioEl
|
||||
}
|
||||
|
||||
// ===== 初始化 & 连接 =====
|
||||
async function init() {
|
||||
try {
|
||||
const r = await invoke<{ activeId: string; list: FeiniuConnection[] }>('feiniu_list_connections')
|
||||
connections.value = r?.list || []
|
||||
activeId.value = r?.activeId || ''
|
||||
const c = await invoke<{ baseUrl: string; username: string; loggedIn: boolean }>('feiniu_get_config')
|
||||
config.value = c
|
||||
if (c?.loggedIn) await refreshMediaPrefix()
|
||||
} catch (e) {
|
||||
logger.error(`初始化失败: ${e}`)
|
||||
}
|
||||
loadPlaylists()
|
||||
restoreQueue()
|
||||
try {
|
||||
volume.value = Number(localStorage.getItem(VOL_KEY)) || 0.8
|
||||
const cm = localStorage.getItem('thing.music.cachemode')
|
||||
if (cm === 'cache' || cm === 'stream') cacheMode.value = cm
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
await ensureCacheStatus()
|
||||
try {
|
||||
libraryNasPath.value = localStorage.getItem('thing.music.feiniu.naspath') || ''
|
||||
autoUpload.value = localStorage.getItem('thing.music.feiniu.autoupload') === '1'
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
await refreshFnosStatus()
|
||||
}
|
||||
|
||||
async function refreshFnosStatus() {
|
||||
if (!activeId.value) {
|
||||
fnosLoggedIn.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const r = await invoke<{ loggedIn: boolean }>('feiniu_fnos_status', { connectionId: activeId.value })
|
||||
fnosLoggedIn.value = !!r?.loggedIn
|
||||
} catch {
|
||||
fnosLoggedIn.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fnosLogin(username: string, password: string) {
|
||||
if (!activeId.value) throw new Error('请先激活一个连接')
|
||||
await invoke('feiniu_fnos_login', { connectionId: activeId.value, username, password })
|
||||
fnosLoggedIn.value = true
|
||||
}
|
||||
|
||||
function fnosLogout() {
|
||||
if (activeId.value) invoke('feiniu_fnos_logout', { connectionId: activeId.value }).catch(() => {})
|
||||
fnosLoggedIn.value = false
|
||||
}
|
||||
|
||||
function setLibraryNasPath(v: string) {
|
||||
libraryNasPath.value = v
|
||||
try {
|
||||
localStorage.setItem('thing.music.feiniu.naspath', v)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function setAutoUpload(v: boolean) {
|
||||
autoUpload.value = v
|
||||
try {
|
||||
localStorage.setItem('thing.music.feiniu.autoupload', v ? '1' : '0')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** 上传单个本地文件到飞牛曲库目录(激活连接)。 */
|
||||
async function uploadToFeiniu(localPath: string, fileName: string): Promise<void> {
|
||||
if (!fnosLoggedIn.value) throw new Error('请先登录 NAS 文件服务')
|
||||
const base = libraryNasPath.value.replace(/[\\/]+$/, '')
|
||||
const nasPath = base ? `${base}/${fileName}` : fileName
|
||||
uploading.value = true
|
||||
try {
|
||||
await invoke('feiniu_fnos_upload', { localPath, nasPath })
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 上传一个本地曲目(自动取文件名)到飞牛曲库。 */
|
||||
async function uploadLocalTrack(item: PlayableItem): Promise<void> {
|
||||
if (!item.guid) throw new Error('缺少本地文件路径')
|
||||
const name = item.guid.split(/[\\/]/).pop() || 'music.bin'
|
||||
await uploadToFeiniu(item.guid, name)
|
||||
}
|
||||
|
||||
/** 把最近 N 分钟内新出现的本地音频批量上传到飞牛曲库。返回 { total, ok }。 */
|
||||
async function uploadRecentToFeiniu(minutes = 10): Promise<{ total: number; ok: number }> {
|
||||
if (!fnosLoggedIn.value) throw new Error('请先连接 NAS 文件服务(设置 → 飞牛连接)')
|
||||
if (!libraryNasPath.value) throw new Error('请先填写飞牛曲库目录(设置 → 上传到飞牛)')
|
||||
const r = await invoke<{ items: any[] }>('feiniu_scan_local')
|
||||
const now = Date.now() / 1000
|
||||
const recent = (r?.items || []).filter((f) => now - (f.mtim || 0) <= minutes * 60)
|
||||
let ok = 0
|
||||
uploading.value = true
|
||||
try {
|
||||
for (const f of recent) {
|
||||
const path = String(f.path)
|
||||
const name = path.split(/[\\/]/).pop() || 'music.bin'
|
||||
try {
|
||||
await uploadToFeiniu(path, name)
|
||||
ok++
|
||||
} catch {
|
||||
/* 单文件失败继续 */
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
return { total: recent.length, ok }
|
||||
}
|
||||
|
||||
/** 删除 NAS 上的文件(激活连接)。 */
|
||||
async function deleteFromFeiniu(nasPath: string): Promise<void> {
|
||||
await invoke('feiniu_fnos_delete', { nasPath })
|
||||
}
|
||||
|
||||
/** FnConnect:解析 fnId 得到可达 base_url。 */
|
||||
async function resolveFnConnect(fnId: string): Promise<{ baseUrl: string; relay: boolean }> {
|
||||
return invoke('feiniu_fnconnect_resolve', { fnId })
|
||||
}
|
||||
|
||||
async function ensureCacheStatus() {
|
||||
try {
|
||||
const r = await invoke<{ count: number; usedMb: number }>('feiniu_cache_status')
|
||||
cacheStatus.value = { count: r?.count ?? 0, usedMb: r?.usedMb ?? 0 }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function setCacheMode(mode: 'stream' | 'cache') {
|
||||
cacheMode.value = mode
|
||||
try {
|
||||
localStorage.setItem('thing.music.cachemode', mode)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (mode === 'stream') clearPlayback()
|
||||
}
|
||||
|
||||
async function clearCache() {
|
||||
await invoke('feiniu_cache_clear')
|
||||
await ensureCacheStatus()
|
||||
clearPlayback()
|
||||
}
|
||||
|
||||
async function refreshConnections() {
|
||||
try {
|
||||
const r = await invoke<{ activeId: string; list: FeiniuConnection[] }>('feiniu_list_connections')
|
||||
connections.value = r?.list || []
|
||||
activeId.value = r?.activeId || ''
|
||||
await refreshFnosStatus()
|
||||
const c = await invoke<{ baseUrl: string; username: string; loggedIn: boolean }>('feiniu_get_config')
|
||||
config.value = c
|
||||
} catch (e) {
|
||||
logger.error(String(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConnection(conn: Partial<FeiniuConnection> & { name: string; baseUrl: string; kind: string }): Promise<string> {
|
||||
const r = await invoke<{ ok: boolean }>('feiniu_save_connection', {
|
||||
connection: {
|
||||
id: conn.id || '',
|
||||
name: conn.name,
|
||||
kind: conn.kind,
|
||||
baseUrl: conn.baseUrl,
|
||||
username: conn.username || '',
|
||||
token: '',
|
||||
deviceId: '',
|
||||
accessCode: conn.accessCode || '',
|
||||
insecure: conn.insecure || false,
|
||||
fnId: conn.fnId || ''
|
||||
}
|
||||
})
|
||||
void r
|
||||
await refreshConnections()
|
||||
return connections.value.find((c) => c.baseUrl === conn.baseUrl && c.name === conn.name)?.id || ''
|
||||
}
|
||||
|
||||
async function deleteConnection(id: string) {
|
||||
await invoke('feiniu_delete_connection', { id })
|
||||
await refreshConnections()
|
||||
}
|
||||
|
||||
async function activateConnection(id: string) {
|
||||
await invoke('feiniu_activate_connection', { id })
|
||||
await refreshConnections()
|
||||
if (config.value.loggedIn) await refreshMediaPrefix()
|
||||
await refreshFnosStatus()
|
||||
}
|
||||
|
||||
async function login(connectionId: string, username: string, password: string) {
|
||||
connecting.value = true
|
||||
try {
|
||||
const r = await invoke<{ mediaPrefix: string }>('feiniu_login', {
|
||||
connectionId,
|
||||
username,
|
||||
password
|
||||
})
|
||||
await refreshConnections()
|
||||
config.value = { baseUrl: activeConn.value?.baseUrl || '', username, loggedIn: true }
|
||||
if (r?.mediaPrefix) mediaPrefix.value = r.mediaPrefix
|
||||
await loadTracks(1)
|
||||
} catch (e) {
|
||||
logger.error(String(e))
|
||||
throw e
|
||||
} finally {
|
||||
connecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function logout(connectionId: string) {
|
||||
try {
|
||||
await invoke('feiniu_logout', { connectionId })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
await refreshConnections()
|
||||
config.value.loggedIn = false
|
||||
mediaPrefix.value = ''
|
||||
tracks.value = []
|
||||
clearPlayback()
|
||||
}
|
||||
|
||||
async function testConnection(id: string, username: string, password: string): Promise<boolean> {
|
||||
const r = await invoke<{ ok: boolean }>('feiniu_test_connection', { connectionId: id, username, password })
|
||||
return !!r?.ok
|
||||
}
|
||||
|
||||
async function refreshMediaPrefix() {
|
||||
try {
|
||||
const r = await invoke<{ mediaPrefix: string }>('feiniu_media_prefix')
|
||||
mediaPrefix.value = r?.mediaPrefix || ''
|
||||
} catch (e) {
|
||||
logger.error(String(e))
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 飞牛曲库 =====
|
||||
async function loadTracks(p: number = 1): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await invoke<any>('feiniu_list_tracks', {
|
||||
page: p,
|
||||
size: 50,
|
||||
keyword: keyword.value.trim() || null
|
||||
})
|
||||
const list = Array.isArray(data) ? data : data?.list || data?.items || data?.tracks || []
|
||||
tracks.value = (list as any[]).map((t) => normalizeTrack(t))
|
||||
total.value = data?.total ?? data?.count ?? list.length
|
||||
page.value = p
|
||||
} catch (e) {
|
||||
logger.error(String(e))
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTrack(t: any): PlayableItem {
|
||||
const artists = Array.isArray(t.artists) ? t.artists : []
|
||||
const album = typeof t.album === 'string' ? t.album : t.album?.name || t.albumName || ''
|
||||
return {
|
||||
source: 'feiniu',
|
||||
guid: t.guid || t.trackGuid || t.id,
|
||||
title: t.title || t.name || '未知标题',
|
||||
durationMs: t.durationMs ?? t.duration ?? undefined,
|
||||
coverId: t.coverId || undefined,
|
||||
album,
|
||||
artistNames:
|
||||
artists.map((a: { name?: string }) => a.name).filter(Boolean).join(' / ') ||
|
||||
t.artist ||
|
||||
t.singers ||
|
||||
''
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 本地曲库 =====
|
||||
async function scanLocal() {
|
||||
localScanBusy.value = true
|
||||
try {
|
||||
const data = await invoke<{ items: any[] }>('feiniu_scan_local')
|
||||
localTracks.value = (data?.items || []).map((f) => ({
|
||||
source: 'local' as const,
|
||||
guid: f.path,
|
||||
title: f.title || f.name || '未知',
|
||||
artistNames: '',
|
||||
durationMs: undefined,
|
||||
size: f.size,
|
||||
dir: f.dir
|
||||
}))
|
||||
} catch (e) {
|
||||
logger.error(String(e))
|
||||
throw e
|
||||
} finally {
|
||||
localScanBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 歌单 =====
|
||||
function loadPlaylists() {
|
||||
try {
|
||||
const raw = localStorage.getItem(PLAYERS_KEY)
|
||||
if (!raw) return
|
||||
playlists.value = JSON.parse(raw)
|
||||
} catch {
|
||||
playlists.value = []
|
||||
}
|
||||
}
|
||||
function persistPlaylists() {
|
||||
try {
|
||||
localStorage.setItem(PLAYERS_KEY, JSON.stringify(playlists.value))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
function createPlaylist(name: string): string {
|
||||
const id = uid()
|
||||
playlists.value.push({ id, name, items: [] })
|
||||
persistPlaylists()
|
||||
return id
|
||||
}
|
||||
function renamePlaylist(id: string, name: string) {
|
||||
const p = playlists.value.find((p) => p.id === id)
|
||||
if (p) {
|
||||
p.name = name
|
||||
persistPlaylists()
|
||||
}
|
||||
}
|
||||
function deletePlaylist(id: string) {
|
||||
playlists.value = playlists.value.filter((p) => p.id !== id)
|
||||
persistPlaylists()
|
||||
}
|
||||
function addToPlaylist(playlistId: string, items: PlayableItem[]) {
|
||||
const p = playlists.value.find((p) => p.id === playlistId)
|
||||
if (!p) return
|
||||
for (const it of items) {
|
||||
const dup = p.items.some((x) => x.guid === it.guid && x.source === it.source)
|
||||
if (!dup) p.items.push({ ...it })
|
||||
}
|
||||
persistPlaylists()
|
||||
}
|
||||
function removeFromPlaylist(playlistId: string, index: number) {
|
||||
const p = playlists.value.find((p) => p.id === playlistId)
|
||||
if (p) {
|
||||
p.items.splice(index, 1)
|
||||
persistPlaylists()
|
||||
}
|
||||
}
|
||||
function moveInPlaylist(playlistId: string, from: number, to: number) {
|
||||
const p = playlists.value.find((p) => p.id === playlistId)
|
||||
if (!p || from < 0 || to < 0 || from >= p.items.length || to >= p.items.length) return
|
||||
const [it] = p.items.splice(from, 1)
|
||||
p.items.splice(to, 0, it)
|
||||
persistPlaylists()
|
||||
}
|
||||
|
||||
// ===== 播放器 =====
|
||||
function playQueue(items: PlayableItem[], startIndex = 0) {
|
||||
queue.value = items.map((i) => ({ ...i }))
|
||||
queueIndex.value = Math.min(Math.max(startIndex, 0), items.length - 1)
|
||||
playCurrent()
|
||||
}
|
||||
|
||||
function playItem(item: PlayableItem) {
|
||||
// 若已在队列中,跳转到它;否则作为新队列播放
|
||||
const i = queue.value.findIndex((q) => q.guid === item.guid && q.source === item.source)
|
||||
if (i >= 0) {
|
||||
queueIndex.value = i
|
||||
playCurrent()
|
||||
} else {
|
||||
queue.value = [{ ...item }]
|
||||
queueIndex.value = 0
|
||||
playCurrent()
|
||||
}
|
||||
}
|
||||
|
||||
function playCurrent() {
|
||||
const t = current.value
|
||||
if (!t) return
|
||||
if (t.source === 'feiniu') loadLyric(t.guid)
|
||||
else lyricLines.value = []
|
||||
const a = ensureAudio()
|
||||
a.pause()
|
||||
if (t.source === 'feiniu' && cacheMode.value === 'cache') {
|
||||
playCached(t, a)
|
||||
return
|
||||
}
|
||||
a.src =
|
||||
t.source === 'feiniu'
|
||||
? `${mediaPrefix.value}/stream?guid=${encodeURIComponent(t.guid || '')}`
|
||||
: `file://${(t.guid || '').replace(/\\/g, '/')}`
|
||||
a.currentTime = 0
|
||||
startPlayback(a)
|
||||
}
|
||||
|
||||
async function playCached(t: PlayableItem, a: HTMLAudioElement) {
|
||||
loadingPlay.value = true
|
||||
try {
|
||||
const r = await invoke<{ path: string | null }>('feiniu_cache_fetch', { guid: t.guid || '' })
|
||||
if (r?.path) {
|
||||
a.src = `file://${(r.path as string).replace(/\\/g, '/')}`
|
||||
a.currentTime = 0
|
||||
startPlayback(a)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
/* fall through to stream */
|
||||
}
|
||||
// 缓存失败(未登录/网络)→ 回退直连流
|
||||
a.src = `${mediaPrefix.value}/stream?guid=${encodeURIComponent(t.guid || '')}`
|
||||
a.currentTime = 0
|
||||
startPlayback(a)
|
||||
}
|
||||
|
||||
function startPlayback(a: HTMLAudioElement) {
|
||||
loadingPlay.value = true
|
||||
a.play()
|
||||
.then(() => {
|
||||
loadingPlay.value = false
|
||||
persistQueue()
|
||||
})
|
||||
.catch(() => {
|
||||
loadingPlay.value = false
|
||||
persistQueue()
|
||||
})
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
const a = ensureAudio()
|
||||
if (a.paused) a.play().catch(() => {})
|
||||
else a.pause()
|
||||
}
|
||||
|
||||
function next(manual = true) {
|
||||
const n = queue.value.length
|
||||
if (n === 0) return
|
||||
if (manual && playMode.value === 'shuffle') {
|
||||
let idx = queueIndex.value
|
||||
while (idx === queueIndex.value && n > 1) idx = Math.floor(Math.random() * n)
|
||||
queueIndex.value = idx
|
||||
} else if (queueIndex.value < n - 1) {
|
||||
queueIndex.value++
|
||||
} else if (playMode.value !== 'loopOne') {
|
||||
queueIndex.value = 0
|
||||
} else {
|
||||
// loopOne 且已到末尾:保持当前
|
||||
queueIndex.value = 0
|
||||
}
|
||||
playCurrent()
|
||||
}
|
||||
|
||||
function onEnded() {
|
||||
if (playMode.value === 'loopOne') {
|
||||
const a = ensureAudio()
|
||||
a.currentTime = 0
|
||||
a.play().catch(() => {})
|
||||
return
|
||||
}
|
||||
next(false)
|
||||
}
|
||||
|
||||
function prev() {
|
||||
const a = ensureAudio()
|
||||
if (a.currentTime > 3) {
|
||||
a.currentTime = 0
|
||||
return
|
||||
}
|
||||
if (queueIndex.value > 0) queueIndex.value--
|
||||
else queueIndex.value = 0
|
||||
playCurrent()
|
||||
}
|
||||
|
||||
function seek(sec: number) {
|
||||
const a = ensureAudio()
|
||||
a.currentTime = Math.max(0, sec)
|
||||
position.value = a.currentTime
|
||||
}
|
||||
|
||||
function setVolume(v: number) {
|
||||
volume.value = v
|
||||
const a = ensureAudio()
|
||||
a.volume = v
|
||||
try {
|
||||
localStorage.setItem(VOL_KEY, String(v))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function togglePlayMode() {
|
||||
playMode.value = playMode.value === 'loopAll' ? 'loopOne' : playMode.value === 'loopOne' ? 'shuffle' : 'loopAll'
|
||||
}
|
||||
|
||||
function clearPlayback() {
|
||||
queue.value = []
|
||||
queueIndex.value = -1
|
||||
lyricLines.value = []
|
||||
position.value = 0
|
||||
duration.value = 0
|
||||
playing.value = false
|
||||
if (audioEl) {
|
||||
audioEl.pause()
|
||||
audioEl.removeAttribute('src')
|
||||
audioEl.load()
|
||||
}
|
||||
}
|
||||
|
||||
function restoreQueue() {
|
||||
try {
|
||||
const raw = localStorage.getItem(QUEUE_KEY)
|
||||
if (!raw) return
|
||||
const s = JSON.parse(raw)
|
||||
if (!s?.queue || !Array.isArray(s.queue)) return
|
||||
queue.value = s.queue
|
||||
queueIndex.value = typeof s.index === 'number' ? s.index : -1
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function persistQueue() {
|
||||
try {
|
||||
localStorage.setItem(QUEUE_KEY, JSON.stringify({ queue: queue.value, index: queueIndex.value }))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function fmtDuration(sec?: number): string {
|
||||
if (sec == null || !isFinite(sec)) return '--:--'
|
||||
const s = Math.floor(sec)
|
||||
const m = Math.floor(s / 60)
|
||||
const r = s % 60
|
||||
return `${String(m).padStart(2, '0')}:${String(r).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
async function loadLyric(guid?: string) {
|
||||
lyricLines.value = []
|
||||
if (!guid) return
|
||||
try {
|
||||
const r = await invoke<{ lyric: string }>('feiniu_lyric', { guid })
|
||||
const text = r?.lyric || ''
|
||||
if (text === lyricRaw) return
|
||||
lyricRaw = text
|
||||
lyricLines.value = parseLrc(text)
|
||||
} catch (e) {
|
||||
logger.error(String(e))
|
||||
}
|
||||
}
|
||||
|
||||
function parseLrc(text: string): LrcLine[] {
|
||||
const lines: LrcLine[] = []
|
||||
const lineRe = /\[(\d{1,2}):(\d{1,2})(?:[.:](\d{1,3}))?]/g
|
||||
const parts = text.split(/\r?\n/)
|
||||
for (const part of parts) {
|
||||
const timestamps: number[] = []
|
||||
let src = part
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = lineRe.exec(part))) {
|
||||
const mm = Number(m[1])
|
||||
const ss = Number(m[2])
|
||||
const frac = m[3] ? Number(m[3].padEnd(3, '0')) / 1000 : 0
|
||||
timestamps.push(mm * 60 + ss + frac)
|
||||
src = part.slice(lineRe.lastIndex)
|
||||
}
|
||||
const content = src.replace(/^\[[^\]]*]\s*/, '').trim()
|
||||
if (!content) continue
|
||||
for (const t of timestamps) lines.push({ t, text: content })
|
||||
}
|
||||
lines.sort((a, b) => a.t - b.t)
|
||||
return lines
|
||||
}
|
||||
|
||||
function currentLine(): number {
|
||||
let idx = -1
|
||||
for (let k = 0; k < lyricLines.value.length; k++) {
|
||||
if (lyricLines.value[k].t <= position.value) idx = k
|
||||
else break
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
const progress = computed(() => (duration.value > 0 ? (position.value / duration.value) * 100 : 0))
|
||||
|
||||
return {
|
||||
connections,
|
||||
activeId,
|
||||
activeConn,
|
||||
config,
|
||||
mediaPrefix,
|
||||
connecting,
|
||||
cacheMode,
|
||||
cacheStatus,
|
||||
tracks,
|
||||
page,
|
||||
total,
|
||||
loading,
|
||||
keyword,
|
||||
localTracks,
|
||||
localScanBusy,
|
||||
playlists,
|
||||
queue,
|
||||
queueIndex,
|
||||
playMode,
|
||||
volume,
|
||||
current,
|
||||
playing,
|
||||
loadingPlay,
|
||||
position,
|
||||
duration,
|
||||
lyricLines,
|
||||
lyricVisible,
|
||||
queueVisible,
|
||||
nowPlayingOpen,
|
||||
progress,
|
||||
init,
|
||||
refreshConnections,
|
||||
saveConnection,
|
||||
deleteConnection,
|
||||
activateConnection,
|
||||
login,
|
||||
logout,
|
||||
testConnection,
|
||||
refreshMediaPrefix,
|
||||
loadTracks,
|
||||
scanLocal,
|
||||
ensureCacheStatus,
|
||||
setCacheMode,
|
||||
clearCache,
|
||||
fnosLoggedIn,
|
||||
libraryNasPath,
|
||||
autoUpload,
|
||||
uploading,
|
||||
refreshFnosStatus,
|
||||
fnosLogin,
|
||||
fnosLogout,
|
||||
setLibraryNasPath,
|
||||
setAutoUpload,
|
||||
uploadToFeiniu,
|
||||
uploadLocalTrack,
|
||||
uploadRecentToFeiniu,
|
||||
deleteFromFeiniu,
|
||||
resolveFnConnect,
|
||||
createPlaylist,
|
||||
renamePlaylist,
|
||||
deletePlaylist,
|
||||
addToPlaylist,
|
||||
removeFromPlaylist,
|
||||
moveInPlaylist,
|
||||
playQueue,
|
||||
playItem,
|
||||
toggle,
|
||||
next,
|
||||
prev,
|
||||
seek,
|
||||
setVolume,
|
||||
togglePlayMode,
|
||||
clearPlayback,
|
||||
persistQueue,
|
||||
fmtDuration,
|
||||
currentLine
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,595 @@
|
||||
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<string, string> | null
|
||||
defaultDownloadCookies?: Record<string, string> | null
|
||||
/** 懒解析:官方搜索 API 的原始结果(resolve/下载前解析真实链接用) */
|
||||
rawSearch?: Record<string, unknown> | 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<string, MusicSong[]>
|
||||
total: number
|
||||
sources: string[]
|
||||
}
|
||||
|
||||
/** 桥接 ping 返回 */
|
||||
export interface MusicPingPayload {
|
||||
version: string
|
||||
python: string
|
||||
}
|
||||
|
||||
const logger = createLogger('music')
|
||||
|
||||
export const useMusicStore = defineStore('music', () => {
|
||||
const settings = ref<MusicSettings | null>(null)
|
||||
const env = ref<MusicEnvStatus | null>(null)
|
||||
/** musicdl 已注册的全部搜索源(客户端名) */
|
||||
const availableSources = ref<string[]>([])
|
||||
|
||||
// 搜索状态
|
||||
const searching = ref(false)
|
||||
const searchError = ref('')
|
||||
const results = ref<Record<string, MusicSong[]>>({})
|
||||
const resultTotal = ref(0)
|
||||
/** 已执行过一次搜索/歌单解析(用于空结果时展示"未找到"而非初始引导空态) */
|
||||
const searched = ref(false)
|
||||
|
||||
// 运行时安装状态
|
||||
const installing = ref(false)
|
||||
const installProgress = ref<MusicInstallProgress | null>(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<MusicPingPayload> {
|
||||
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<MusicDownloadTask[]>([])
|
||||
let downloadUnlisten: UnlistenFn | null = null
|
||||
|
||||
// 任务历史持久化(localStorage,应用重启后保留记录;恢复时不尝试续传)
|
||||
const TASKS_KEY = 'thing.music.tasks'
|
||||
const MAX_TASKS = 30
|
||||
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function persistTasks() {
|
||||
try {
|
||||
localStorage.setItem(TASKS_KEY, JSON.stringify(tasks.value.slice(0, MAX_TASKS)))
|
||||
} 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<MusicDownloadEvent>('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) {
|
||||
st.status = 'done'
|
||||
st.downloaded = st.total || st.downloaded
|
||||
}
|
||||
task.doneCount++
|
||||
} 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 */
|
||||
async function resolveSong(source: string, index: number): Promise<MusicSong | null> {
|
||||
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 })) 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 桥接,返回 0 */
|
||||
async function startDownload(
|
||||
songs: MusicSong[],
|
||||
opts: {
|
||||
savedir: string
|
||||
lyric: boolean
|
||||
cover: boolean
|
||||
proxyUrl: string
|
||||
engine: string
|
||||
maxConcurrent: number
|
||||
/** 目标音质 label("" 表示最高);解析下载时按「≤所选最优档」封顶 */
|
||||
quality?: string
|
||||
}
|
||||
): Promise<number> {
|
||||
if (songs.length === 0) return 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 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 0
|
||||
}
|
||||
|
||||
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: s.defaultDownloadQuality ?? ''
|
||||
})
|
||||
}
|
||||
|
||||
async function installRuntime() {
|
||||
if (installing.value) return
|
||||
installing.value = true
|
||||
installError.value = ''
|
||||
installProgress.value = null
|
||||
if (!progressUnlisten) {
|
||||
progressUnlisten = await listen<MusicInstallProgress>(
|
||||
'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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user