[+] Prepare page
This commit is contained in:
@@ -30,5 +30,10 @@ export const API = {
|
|||||||
user: {
|
user: {
|
||||||
createSyncCode: async () => await post('/api/user/sync-code', {}),
|
createSyncCode: async () => await post('/api/user/sync-code', {}),
|
||||||
loginWithSyncCode: async (code: string) => await post('/api/auth/login', { code })
|
loginWithSyncCode: async (code: string) => await post('/api/auth/login', { code })
|
||||||
|
},
|
||||||
|
|
||||||
|
song: {
|
||||||
|
prepare: async (id: number) => await post(`/api/song/${id}/prepare`, {}),
|
||||||
|
status: async (id: number) => await fetch(`/api/song/${id}/prepare`).then(res => res.json())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-9
@@ -11,6 +11,9 @@ import path from 'path'
|
|||||||
|
|
||||||
const CACHE_DIR = path.resolve('static/audio')
|
const CACHE_DIR = path.resolve('static/audio')
|
||||||
|
|
||||||
|
const checkNetease = async () => (await ne.login_status({}) as any).body.data.account
|
||||||
|
const eToString = (e: any) => e.message ?? e.body.message
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Functional wrapper to cache API results to MongoDB.
|
* Functional wrapper to cache API results to MongoDB.
|
||||||
* @param collectionName Name of the MongoDB collection.
|
* @param collectionName Name of the MongoDB collection.
|
||||||
@@ -37,17 +40,14 @@ function parsePlaylistRef(ref: string): number {
|
|||||||
const urlMatch = ref.match(/playlist\?id=(\d+)/)
|
const urlMatch = ref.match(/playlist\?id=(\d+)/)
|
||||||
if (urlMatch) return +urlMatch[1]
|
if (urlMatch) return +urlMatch[1]
|
||||||
if (/^\d+$/.test(ref)) return +ref
|
if (/^\d+$/.test(ref)) return +ref
|
||||||
throw new Error('Invalid playlist reference')
|
throw error(400, "无法识别歌单 URL... 只支持网易云的 URL 哦")
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPlaylistRaw = cached('playlists_raw',
|
const getPlaylistRaw = cached('playlists_raw',
|
||||||
async (id: number) => {
|
async (id: number) => {
|
||||||
const pl = ((await ne.playlist_detail({ id })).body as any).playlist
|
const pl = ((await ne.playlist_detail({ id })).body as any).playlist
|
||||||
|
|
||||||
// Save each song
|
|
||||||
for (const track of pl.tracks)
|
for (const track of pl.tracks)
|
||||||
await db.collection('songs_raw').replaceOne({ _id: track.id }, { _id: track.id, data: track }, { upsert: true })
|
await db.collection('songs_raw').replaceOne({ _id: track.id }, { _id: track.id, data: track }, { upsert: true })
|
||||||
|
|
||||||
return pl
|
return pl
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -100,23 +100,81 @@ export const getSongUrl = async (id: number | string) => {
|
|||||||
const publicUrl = `/audio/${id}/standard.mp3`
|
const publicUrl = `/audio/${id}/standard.mp3`
|
||||||
if (await fs.exists(filePath)) return publicUrl
|
if (await fs.exists(filePath)) return publicUrl
|
||||||
|
|
||||||
|
// Check netease api status
|
||||||
|
if (await checkNetease() === null) throw error(500, '服务器的网易云账号坏掉了 :(')
|
||||||
|
|
||||||
console.log(`Downloading song ${id}...`)
|
console.log(`Downloading song ${id}...`)
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const res = await ne.song_url_v1({ id: id.toString(), level: 'standard' })
|
const res = await ne.song_url_v1({ id: id.toString(), level: 'standard' })
|
||||||
const url = (res.body as any).data?.[0]?.url
|
const url = (res.body as any).data?.[0]?.url
|
||||||
|
|
||||||
if (!url) throw error(404, 'Song URL not found')
|
if (!url) throw error(404, '没获取到歌曲 URL(是不是被下架了)')
|
||||||
|
|
||||||
const audioRes = await fetch(url)
|
const audioRes = await fetch(url)
|
||||||
if (!audioRes.ok) throw error(500, 'Failed to download song')
|
if (!audioRes.ok) throw error(500, '歌曲下载失败')
|
||||||
|
|
||||||
const buffer = await audioRes.arrayBuffer()
|
const buffer = await audioRes.arrayBuffer()
|
||||||
|
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
||||||
await fs.writeFile(filePath, Buffer.from(buffer))
|
await fs.writeFile(filePath, Buffer.from(buffer))
|
||||||
console.log(`Song ${id} cached to ${filePath}`)
|
console.log(`Song ${id} cached to ${filePath}`)
|
||||||
|
|
||||||
return publicUrl
|
return publicUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /////////////////////////////////////////////////////////////////////////////
|
||||||
|
// API for Song Preparation
|
||||||
|
|
||||||
|
export interface ProgressItem { task: string, progress: number }
|
||||||
|
export interface SongProcessState { items: ProgressItem[], status: 'running' | 'done' | 'error' }
|
||||||
|
|
||||||
|
const songProcessingStatus = new Map<number, SongProcessState>()
|
||||||
|
export const getSongStatus = (songId: number) => songProcessingStatus.get(songId) || { items: [], status: 'idle' }
|
||||||
|
export const checkLyricsProcessed = async (songId: number) => !!await db.collection('lyrics_processed').findOne({ _id: songId as any })
|
||||||
|
export const prepareSong = async (songId: number) => {
|
||||||
|
if (songProcessingStatus.has(songId)) return
|
||||||
|
|
||||||
|
const state: SongProcessState = { items: [], status: 'running' }
|
||||||
|
songProcessingStatus.set(songId, state)
|
||||||
|
|
||||||
|
const addTask = (task: string) => ({ task, progress: 0 }).also(it => state.items.push(it))
|
||||||
|
try {
|
||||||
|
// 1. Get Lyrics
|
||||||
|
const taskLyrics = addTask('从网易云获取歌词')
|
||||||
|
const raw = await getLyricsRaw(songId)
|
||||||
|
taskLyrics.progress = 1
|
||||||
|
|
||||||
|
if (raw.lang !== 'jpn') {
|
||||||
|
addTask('错误: 不是日语歌曲').progress = -1
|
||||||
|
return state.status = 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. AI Process
|
||||||
|
const taskAI = addTask('AI 标注歌词读音')
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
if (await checkLyricsProcessed(songId)) taskAI.progress = 1
|
||||||
|
else {
|
||||||
|
const lrc = await aiParseLyrics(raw.lrc.lyric, (i, n) => taskAI.progress = i / n)
|
||||||
|
await db.collection('lyrics_processed').replaceOne({ _id: songId as any }, { _id: songId, data: lrc }, { upsert: true })
|
||||||
|
taskAI.progress = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Audio
|
||||||
|
const taskAudio = addTask('从网易云获取音乐')
|
||||||
|
await getSongUrl(songId)
|
||||||
|
taskAudio.progress = 1
|
||||||
|
|
||||||
|
state.status = 'done'
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
addTask(`错误: ${eToString(e)}`).progress = -1
|
||||||
|
state.status = 'error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// /////////////////////////////////////////////////////////////////////////////
|
||||||
|
// API for Netease Import
|
||||||
|
|
||||||
export interface ImportSession {
|
export interface ImportSession {
|
||||||
id: string
|
id: string
|
||||||
@@ -128,7 +186,6 @@ export interface ImportSession {
|
|||||||
}[]
|
}[]
|
||||||
done: boolean
|
done: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessions = new Map<string, ImportSession>()
|
const sessions = new Map<string, ImportSession>()
|
||||||
|
|
||||||
export const getSession = (id: string) => sessions.get(id)
|
export const getSession = (id: string) => sessions.get(id)
|
||||||
@@ -156,7 +213,6 @@ export async function startImport(link: string, userId?: number): Promise<Import
|
|||||||
console.log(`Import session ${importId} already exists`)
|
console.log(`Import session ${importId} already exists`)
|
||||||
return sessions.get(importId)!
|
return sessions.get(importId)!
|
||||||
}
|
}
|
||||||
|
|
||||||
sessions.set(importId, session)
|
sessions.set(importId, session)
|
||||||
|
|
||||||
// Start background processing
|
// Start background processing
|
||||||
@@ -203,7 +259,8 @@ async function processImport(session: ImportSession, data: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// /////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Default playlists and recommendation
|
||||||
|
|
||||||
// TODO: A better recommendation system
|
// TODO: A better recommendation system
|
||||||
const defaultPlaylists = [17463338036, 13555799996, 14348145982, 14392963638]
|
const defaultPlaylists = [17463338036, 13555799996, 14348145982, 14392963638]
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { json } from '@sveltejs/kit'
|
||||||
|
import { prepareSong, getSongStatus } from '$lib/server/songs'
|
||||||
|
|
||||||
|
export async function POST({ params }) {
|
||||||
|
const songId = +params.id
|
||||||
|
prepareSong(songId) // Start in background
|
||||||
|
return json({ status: 'started' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET({ params }) {
|
||||||
|
const songId = +params.id
|
||||||
|
const status = getSongStatus(songId)
|
||||||
|
return json({ status })
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { getSongRaw } from "$lib/server/songs"
|
||||||
|
|
||||||
|
export const load = async ({ params }) => {
|
||||||
|
const song = await getSongRaw(+params.id)
|
||||||
|
return { song }
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { API } from "$lib/client"
|
||||||
|
import { onMount } from "svelte"
|
||||||
|
import Button from "../../../components/Button.svelte"
|
||||||
|
import AppBar from "../../../components/appbar/AppBar.svelte"
|
||||||
|
import { goto } from "$app/navigation"
|
||||||
|
import { artistAndAlbum } from "../../../shared/tools.ts"
|
||||||
|
import ProgressList from "../../import/netease/ProgressList.svelte"
|
||||||
|
|
||||||
|
let { data } = $props()
|
||||||
|
let loadStatus = $state<"idle" | "loading" | "done">("idle")
|
||||||
|
let progressItems = $state<any[]>([])
|
||||||
|
let progressPercentage = $state(0)
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
startLoading()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function startLoading() {
|
||||||
|
loadStatus = "loading"
|
||||||
|
await API.song.prepare(data.song.id)
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
const res = await API.song.status(data.song.id)
|
||||||
|
const state = res.status
|
||||||
|
|
||||||
|
if (state && state.items) {
|
||||||
|
progressItems = state.items.map((item: any) => ({
|
||||||
|
title: item.task + (item.progress > 0 && item.progress < 1 ? ` (${Math.round(item.progress * 100)}%)` : ''),
|
||||||
|
icon: item.progress === 1 ? 'i-material-symbols:check text-green-500' :
|
||||||
|
item.progress === -1 ? 'i-material-symbols:error text-red-500' :
|
||||||
|
'i-material-symbols:sync animate-spin'
|
||||||
|
}))
|
||||||
|
|
||||||
|
const totalProgress = state.items.reduce((acc: number, cur: any) => acc + Math.max(0, cur.progress), 0)
|
||||||
|
progressPercentage = Math.min(100, Math.round((totalProgress / 3) * 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === "done") {
|
||||||
|
clearInterval(interval)
|
||||||
|
loadStatus = "done"
|
||||||
|
progressPercentage = 100
|
||||||
|
} else if (state.status === "error") {
|
||||||
|
clearInterval(interval)
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AppBar title={data.song.name} sub={artistAndAlbum(data.song)} />
|
||||||
|
|
||||||
|
<ProgressList percentage={progressPercentage} items={progressItems} />
|
||||||
|
|
||||||
|
{#if loadStatus === "done"}
|
||||||
|
<div class="vbox p-16px">
|
||||||
|
<Button big icon="i-material-symbols:play-arrow-rounded" onclick={() => goto(`/song/${data.song.id}/play`)}>开始</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import type { PageServerLoad } from '../$types'
|
import type { PageServerLoad } from './$types'
|
||||||
import { getLyricsProcessed, getSongRaw, getSongUrl, checkLyricsProcessed } from "$lib/server/songs.ts";
|
import { getLyricsProcessed, getSongRaw, getSongUrl, checkLyricsProcessed } from "$lib/server/songs.ts"
|
||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit'
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params }) => {
|
export const load: PageServerLoad = async ({ params }) => {
|
||||||
const songId = +params.id
|
const songId = +params.id
|
||||||
const song = await getSongRaw(songId)
|
const song = await getSongRaw(songId)
|
||||||
const hasLrc = await checkLyricsProcessed(songId)
|
const hasLrc = await checkLyricsProcessed(songId)
|
||||||
|
|
||||||
if (!hasLrc) throw redirect(302, `/song/${songId}/prepare`)
|
if (!hasLrc) throw redirect(302, `/song/${songId}`)
|
||||||
|
|
||||||
const lrc = await getLyricsProcessed(songId)
|
const lrc = await getLyricsProcessed(songId)!
|
||||||
// const audioUrl = await getSongUrl(songId)
|
// const audioUrl = await getSongUrl(songId)
|
||||||
return { song, lrc, audioUrl: undefined }
|
return { song, lrc, audioUrl: undefined }
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user