[+] Netease import
This commit is contained in:
+6
-1
@@ -14,5 +14,10 @@ export async function post(endpoint: string, data: any) {
|
||||
export const API = {
|
||||
post,
|
||||
saveUserData: async (data: Partial<UserData>) => await post('/api/user', data),
|
||||
saveResult: async (data: Omit<ResultDocument, "_id" | "createdAt">) => await post('/api/result', data)
|
||||
saveResult: async (data: Omit<ResultDocument, "_id" | "createdAt">) => await post('/api/result', data),
|
||||
|
||||
netease: {
|
||||
startImport: async (link: string) => await post('/api/import/netease/start', { link }),
|
||||
checkProgress: async (id: string) => await post('/api/import/netease/progress', { id })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { getSongsFromPlaylist, getLyricsRaw } from './songs'
|
||||
import { db } from './db'
|
||||
import type { NeteaseSongBrief } from '../../shared/types'
|
||||
|
||||
export interface ImportSession {
|
||||
id: string
|
||||
playlistId: number
|
||||
songs: {
|
||||
song: NeteaseSongBrief
|
||||
status: 'importing' | 'success' | 'failed-not-japanese' | 'failed-unknown'
|
||||
}[]
|
||||
done: boolean
|
||||
}
|
||||
|
||||
const sessions = new Map<string, ImportSession>()
|
||||
|
||||
export const getSession = (id: string) => sessions.get(id)
|
||||
|
||||
/**
|
||||
* Start an import session
|
||||
* @param link Netease playlist link
|
||||
* @returns Import session
|
||||
*/
|
||||
export async function startImport(link: string): Promise<ImportSession> {
|
||||
const { meta, songs } = await getSongsFromPlaylist(link)
|
||||
const importId = crypto.randomUUID()
|
||||
|
||||
const session: ImportSession = {
|
||||
id: importId,
|
||||
playlistId: meta.id,
|
||||
songs: songs.map(s => ({ song: s, status: 'importing' })),
|
||||
done: false
|
||||
}
|
||||
|
||||
// If there is another session importing the same playlist, return it
|
||||
if (sessions.has(importId)) {
|
||||
console.log(`Import session ${importId} already exists`)
|
||||
return sessions.get(importId)!
|
||||
}
|
||||
|
||||
sessions.set(importId, session)
|
||||
|
||||
// Start background processing
|
||||
processImport(session).catch(err => console.error('Import failed', err))
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an import session
|
||||
* @param session Session to store data for retriving progress
|
||||
*/
|
||||
async function processImport(session: ImportSession) {
|
||||
const validSongs: NeteaseSongBrief[] = []
|
||||
console.log(`Starting import: Playlist ${session.playlistId}`)
|
||||
|
||||
for (let i = 0; i < session.songs.length; i++) {
|
||||
const item = session.songs[i]
|
||||
try {
|
||||
console.log(`Processing song ${item.song.id}`)
|
||||
const lyrics = await getLyricsRaw(item.song.id)
|
||||
if (lyrics.lang === 'jpn') {
|
||||
item.status = 'success'
|
||||
console.log(`Song ${item.song.id} is valid`)
|
||||
validSongs.push(item.song)
|
||||
} else {
|
||||
item.status = 'failed-not-japanese'
|
||||
console.log(`Song ${item.song.id} is not Japanese (is ${lyrics.lang})`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to process song ${item.song.id}`, e)
|
||||
item.status = 'failed-unknown'
|
||||
}
|
||||
}
|
||||
|
||||
session.done = true
|
||||
|
||||
// Save to database
|
||||
if (validSongs.length > 0) {
|
||||
await db.collection('playlists_imported').replaceOne(
|
||||
{ _id: session.playlistId as any },
|
||||
{
|
||||
_id: session.playlistId,
|
||||
songs: validSongs,
|
||||
importedAt: new Date()
|
||||
},
|
||||
{ upsert: true }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@ export const getLyricsRaw = cached('lyrics_raw',
|
||||
export const getLyricsProcessed = cached('lyrics_processed',
|
||||
async (songId: number) => {
|
||||
const raw = await getLyricsRaw(songId)
|
||||
if (raw.lang !== 'ja') throw error(400, 'Lyrics are not in Japanese')
|
||||
if (raw.lang !== 'jpn') throw error(400, 'Lyrics are not in Japanese')
|
||||
console.log(`Processing lyrics for song ${songId}`)
|
||||
return aiParseLyrics(raw.lrc.lyric)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { getSession } from '$lib/server/neteaseImport';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const { id } = await request.json()
|
||||
if (!id) throw error(400, 'Import ID is required')
|
||||
|
||||
const session = getSession(id)
|
||||
if (!session) throw error(404, 'Session not found')
|
||||
|
||||
return json(session);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { startImport } from '$lib/server/neteaseImport';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const { link } = await request.json();
|
||||
if (!link) throw error(400, 'Link is required')
|
||||
|
||||
try {
|
||||
const session = await startImport(link)
|
||||
return json(session)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
throw error(500, 'Failed to start import')
|
||||
}
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getSongsFromPlaylist, listPlaylists } from "$lib/server/songs.ts";
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => ({
|
||||
playlist: await getSongsFromPlaylist("14348145982")
|
||||
})
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { LinearProgress, TextField, TextFieldOutlined } from "m3-svelte";
|
||||
import AppBar from "../../../components/appbar/AppBar.svelte";
|
||||
import Button from "../../../components/Button.svelte";
|
||||
import type { NeteaseSongBrief } from "../../../shared/types";
|
||||
import type { PageProps } from "./$types";
|
||||
import { LinearProgress, TextFieldOutlined } from "m3-svelte"
|
||||
import AppBar from "../../../components/appbar/AppBar.svelte"
|
||||
import Button from "../../../components/Button.svelte"
|
||||
import type { NeteaseSongBrief } from "../../../shared/types"
|
||||
import { API } from "../../../lib/client"
|
||||
|
||||
let link = $state('')
|
||||
|
||||
@@ -12,24 +12,70 @@
|
||||
status: 'importing' | 'success' | 'failed-not-japanese' | 'failed-unknown'
|
||||
}
|
||||
|
||||
let { data }: PageProps = $props()
|
||||
|
||||
let status = $state<'idle' | 'importing' | 'success' | 'error'>('importing')
|
||||
let songs = $state(data.playlist.songs.map(song => ({ song, status: 'importing' })))
|
||||
let status = $state<'idle' | 'importing' | 'success' | 'error'>('idle')
|
||||
let songs = $state<SongImportStatus[]>([])
|
||||
let progress = $derived({
|
||||
total: songs.length,
|
||||
done: songs.filter(song => song.status === 'success').length
|
||||
done: songs.filter(song => song.status !== 'importing').length
|
||||
})
|
||||
|
||||
function statusToIcon(stat: string): string {
|
||||
switch (stat) {
|
||||
case 'importing': return 'i-material-symbols:sync'
|
||||
case 'success': return 'i-material-symbols:check'
|
||||
case 'failed-not-japanese': return 'i-material-symbols:warning'
|
||||
case 'failed-unknown': return 'i-material-symbols:error'
|
||||
case 'importing': return 'i-material-symbols:sync animate-spin'
|
||||
case 'success': return 'i-material-symbols:check text-green-500'
|
||||
case 'failed-not-japanese': return 'i-material-symbols:warning text-yellow-500'
|
||||
case 'failed-unknown': return 'i-material-symbols:error text-red-500'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function startImport() {
|
||||
if (!link) return
|
||||
status = 'importing'
|
||||
songs = []
|
||||
|
||||
try {
|
||||
const data = await API.netease.startImport(link)
|
||||
|
||||
if (data.error) {
|
||||
alert(data.error)
|
||||
status = 'error'
|
||||
return
|
||||
}
|
||||
|
||||
const importId = data.id
|
||||
songs = data.songs
|
||||
|
||||
pollProgress(importId)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
status = 'error'
|
||||
alert('Failed to start import')
|
||||
}
|
||||
}
|
||||
|
||||
async function pollProgress(id: string) {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const data = await API.netease.checkProgress(id)
|
||||
|
||||
if (data.error) {
|
||||
clearInterval(interval)
|
||||
return
|
||||
}
|
||||
|
||||
songs = data.songs
|
||||
|
||||
if (data.done) {
|
||||
clearInterval(interval)
|
||||
status = 'success'
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<AppBar title="从网易云导入"/>
|
||||
@@ -42,26 +88,31 @@
|
||||
<TextFieldOutlined label="网易云歌单链接 / ID" bind:value={link} />
|
||||
</div>
|
||||
|
||||
{#if status === 'importing'}
|
||||
{#if status !== 'idle'}
|
||||
<div class="hbox gap-12px items-end! h-48px p-content">
|
||||
<div class="m3-font-headline-small">正在导入</div>
|
||||
<div class="m3-font-headline-small">
|
||||
{#if status === 'importing'}正在导入{:else if status === 'success'}导入完成{:else}导入出错{/if}
|
||||
</div>
|
||||
<div class="m3-font-label-small pb-3px">{progress.done} / {progress.total} 首歌曲</div>
|
||||
</div>
|
||||
<LinearProgress percent={progress.done / progress.total * 100}/>
|
||||
<LinearProgress percent={progress.total ? progress.done / progress.total * 100 : 0}/>
|
||||
{/if}
|
||||
|
||||
<div class="vbox self-stretch flex-1 overflow-y-auto p-content min-h-0 gap-8px">
|
||||
{#if status !== 'idle'}
|
||||
{#each songs as song}
|
||||
<div class="hbox gap-12px">
|
||||
<span class={statusToIcon(song.status)}></span>
|
||||
<span class="m3-font-title-large">{song.song.name}</span>
|
||||
<div class="hbox gap-12px items-center">
|
||||
<span class="{statusToIcon(song.status)} text-xl"></span>
|
||||
<div class="vbox">
|
||||
<span class="m3-font-title-medium">{song.song.name}</span>
|
||||
<span class="m3-font-body-small mfg-on-surface-variant">{song.song.artists.map(a => a.name).join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="py-16px p-content">
|
||||
<Button big icon="i-material-symbols:download">开始导入</Button>
|
||||
<Button big icon="i-material-symbols:download" onclick={startImport} disabled={status === 'importing'}>开始导入</Button>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user