[O] Rewrite song and playlist logic
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import type { NeteaseSongBrief } from "../../shared/types.ts";
|
||||
import { artistAndAlbum } from "../../shared/tools.ts";
|
||||
import type { NeteaseSong } from "../../shared/types.ts";
|
||||
import ImageListItem from "./ImageListItem.svelte";
|
||||
|
||||
let { info }: { info: NeteaseSongBrief } = $props();
|
||||
let { info }: { info: NeteaseSong } = $props();
|
||||
</script>
|
||||
|
||||
<ImageListItem photoUrl={info.albumPic} title={info.name} text={`${info.artists.map(a => a.name).join(", ")} - ${info.album}`} />
|
||||
<ImageListItem photoUrl={info.al.picUrl} title={info.name} text={artistAndAlbum(info)} />
|
||||
@@ -1,99 +0,0 @@
|
||||
import { getSongsFromPlaylist, getLyricsRaw } from './songs'
|
||||
import { db } from './db'
|
||||
import type { NeteaseSongBrief, UserDocument } from '../../shared/types'
|
||||
|
||||
export interface ImportSession {
|
||||
id: string
|
||||
playlistId: number
|
||||
userId: any
|
||||
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
|
||||
* @param userId User ID to associate the imported playlist with
|
||||
* @returns Import session
|
||||
*/
|
||||
export async function startImport(link: string, user: UserDocument): Promise<ImportSession> {
|
||||
const { meta, songs } = await getSongsFromPlaylist(link)
|
||||
const importId = crypto.randomUUID()
|
||||
|
||||
const session: ImportSession = {
|
||||
id: importId,
|
||||
playlistId: meta.id,
|
||||
userId: user._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 }
|
||||
)
|
||||
|
||||
// Add to user's favorites
|
||||
await db.collection('users').updateOne(
|
||||
{ _id: session.userId },
|
||||
{ $addToSet: { "data.myPlaylists": session.playlistId } }
|
||||
)
|
||||
}
|
||||
}
|
||||
+123
-48
@@ -1,6 +1,6 @@
|
||||
import * as ne from '@neteasecloudmusicapienhanced/api'
|
||||
import { aiParseLyrics } from './tools/lyrics'
|
||||
import type { NeteaseSongBrief, UserDocument } from '../../shared/types'
|
||||
import type { NeteaseSong, UserDocument } from '../../shared/types'
|
||||
import { db } from './db'
|
||||
import { franc } from 'franc'
|
||||
import { error } from '@sveltejs/kit'
|
||||
@@ -36,61 +36,36 @@ function parsePlaylistRef(ref: string): number {
|
||||
throw new Error('Invalid playlist reference')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get raw playlist data from cache or netease API.
|
||||
*/
|
||||
export const getPlaylistRaw = cached('playlists',
|
||||
const getPlaylistRaw = cached('playlists_raw',
|
||||
async (id: number) => {
|
||||
const pl = ((await ne.playlist_detail({ id })).body as any).playlist
|
||||
|
||||
// Save each song
|
||||
for (const track of pl.tracks) {
|
||||
await db.collection('songs').replaceOne({ _id: track.id }, { _id: track.id, data: track }, { upsert: true })
|
||||
}
|
||||
for (const track of pl.tracks)
|
||||
await db.collection('songs_raw').replaceOne({ _id: track.id }, { _id: track.id, data: track }, { upsert: true })
|
||||
|
||||
return pl
|
||||
})
|
||||
|
||||
// TODO: A better recommendation system
|
||||
export const listRecPlaylists = async () => await db.collection('playlists').find()
|
||||
.map(it => it.data).toArray()
|
||||
|
||||
export const listMyPlaylists = async (user: UserDocument) => (await user.data.myPlaylists?.let(pl => db.collection('playlists').find({
|
||||
_id: { $in: pl as any as ObjectId[] }
|
||||
}).map(it => it.data).toArray())) ?? []
|
||||
|
||||
export const getSongMeta = cached('songs',
|
||||
async (songId: number) => {
|
||||
const detail = await ne.song_detail({ ids: songId.toString() })
|
||||
return detail.body.songs[0]
|
||||
})
|
||||
|
||||
export const parseBrief = (songData: any): NeteaseSongBrief => ({
|
||||
id: songData.id,
|
||||
name: songData.name,
|
||||
album: songData.al.name,
|
||||
albumId: songData.al.id,
|
||||
albumPic: songData.al.picUrl,
|
||||
artists: songData.ar.map((ar: any) => ({ id: ar.id, name: ar.name }))
|
||||
})
|
||||
|
||||
/**
|
||||
* Get a list of songs from a playlist reference.
|
||||
*/
|
||||
export async function getSongsFromPlaylist(ref: string): Promise<{meta: any, songs: NeteaseSongBrief[]}> {
|
||||
const playlistId = parsePlaylistRef(ref)
|
||||
const plData = await getPlaylistRaw(playlistId)
|
||||
return {meta: plData, songs: plData.tracks.map(parseBrief)}
|
||||
function normalizeTimestamps(text: string): string {
|
||||
// Replace all [dd:dd:dd] wit [dd:dd.dd]
|
||||
return text.replace(/\[(\d+):(\d+):(\d+)\]/g, '[$1:$2.$3]')
|
||||
}
|
||||
|
||||
interface NeteaseLyricsResponse { lrc: { lyric: string }, lang: string }
|
||||
|
||||
export const getLyricsRaw = cached('lyrics_raw',
|
||||
const getLyricsRaw = cached('lyrics_raw',
|
||||
async (songId: number) => {
|
||||
const raw = (await ne.lyric({ id: songId })).body as any as NeteaseLyricsResponse
|
||||
const lang = franc(raw.lrc.lyric)
|
||||
const lang = franc(raw.lrc.lyric.replace(/\[.*?\]/g, '').replace(/\s+/g, ' ').trim())
|
||||
raw.lrc.lyric = normalizeTimestamps(raw.lrc.lyric)
|
||||
return { ...raw, lang }
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
export const getSongRaw = cached('songs_raw',
|
||||
async (songId: number) => {
|
||||
const detail = await ne.song_detail({ ids: songId.toString() })
|
||||
return detail.body.songs[0] as NeteaseSong
|
||||
})
|
||||
|
||||
export const getLyricsProcessed = cached('lyrics_processed',
|
||||
async (songId: number) => {
|
||||
@@ -100,10 +75,110 @@ export const getLyricsProcessed = cached('lyrics_processed',
|
||||
return aiParseLyrics(raw.lrc.lyric)
|
||||
})
|
||||
|
||||
await getSongsFromPlaylist("13555799996")
|
||||
await getSongsFromPlaylist("https://music.163.com/playlist?id=14348145982")
|
||||
await getSongsFromPlaylist("https://music.163.com/playlist?id=14392963638")
|
||||
await getSongsFromPlaylist("https://music.163.com/playlist?id=580208139")
|
||||
await getSongsFromPlaylist("https://music.163.com/playlist?id=17404030548")
|
||||
|
||||
export interface ImportSession {
|
||||
id: string
|
||||
playlistId: number
|
||||
userId?: any
|
||||
songs: {
|
||||
song: NeteaseSong
|
||||
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
|
||||
* @param userId User ID to associate the imported playlist with
|
||||
* @returns Import session
|
||||
*/
|
||||
export async function startImport(link: string, userId?: number): Promise<ImportSession> {
|
||||
const meta = await getPlaylistRaw(parsePlaylistRef(link))
|
||||
const importId = crypto.randomUUID()
|
||||
|
||||
const session: ImportSession = {
|
||||
id: importId,
|
||||
playlistId: meta.id,
|
||||
userId,
|
||||
songs: meta.tracks.map((s: any) => ({ 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, meta).catch(err => console.error('Import failed', err))
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an import session
|
||||
* @param session Session to store data for retriving progress
|
||||
* @param data Playlist metadata
|
||||
*/
|
||||
async function processImport(session: ImportSession, data: any) {
|
||||
console.log(`Starting import: Playlist ${session.playlistId}`)
|
||||
data.tracks = (await Promise.all(session.songs.map(async item => {
|
||||
try {
|
||||
const lyrics = await getLyricsRaw(item.song.id)
|
||||
console.log(`Song ${item.song.id} lang ${lyrics.lang}`)
|
||||
if (lyrics.lang === 'jpn') {
|
||||
item.status = 'success'
|
||||
return item.song
|
||||
} else item.status = 'failed-not-japanese'
|
||||
} catch (e) {
|
||||
console.error(`Failed to process song ${item.song.id}`, e)
|
||||
item.status = 'failed-unknown'
|
||||
}
|
||||
}))).filter(it => it !== undefined)
|
||||
|
||||
session.done = true
|
||||
|
||||
// Save to database
|
||||
await db.collection('playlists').replaceOne(
|
||||
{ _id: session.playlistId as any },
|
||||
{ _id: session.playlistId, data, importedAt: new Date() },
|
||||
{ upsert: true }
|
||||
)
|
||||
|
||||
// Add to user's favorites
|
||||
session.userId?.let(async (uid: any) => await db.collection('users').updateOne(
|
||||
{ _id: uid },
|
||||
{ $addToSet: { "data.myPlaylists": session.playlistId } }
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// TODO: A better recommendation system
|
||||
export const listRecPlaylists = async () => await db.collection('playlists').find().limit(10).map(it => it.data).toArray()
|
||||
export const listMyPlaylists = async (user: UserDocument) => (await user.data.myPlaylists?.let(pl => db.collection('playlists').find({
|
||||
_id: { $in: pl as any as ObjectId[] }
|
||||
}).map(it => it.data).toArray())) ?? []
|
||||
|
||||
export const getPlaylist = async (playlistId: number | string) => {
|
||||
const plData = await db.collection('playlists').findOne({ _id: +playlistId as any })
|
||||
if (!plData) throw error(404, 'Playlist not found')
|
||||
return plData.data
|
||||
}
|
||||
|
||||
await startImport("13555799996")
|
||||
await startImport("https://music.163.com/playlist?id=14348145982")
|
||||
await startImport("https://music.163.com/playlist?id=14392963638")
|
||||
await startImport("https://music.163.com/playlist?id=580208139")
|
||||
await startImport("https://music.163.com/playlist?id=17404030548")
|
||||
|
||||
// TODO: Filter out non-Japanese songs
|
||||
|
||||
@@ -187,7 +187,8 @@ export async function aiParseLyricsRaw(raw: string): Promise<LyricLine[]> {
|
||||
|
||||
// Clean up potential markdown blocks
|
||||
const responseText = text.replace(/```/g, '').trim()
|
||||
console.log('AI response text:\n', responseText)
|
||||
console.log('AI request:\n', raw)
|
||||
console.log('AI response:\n', responseText)
|
||||
console.log(`Finish reason: ${response.choices[0].finish_reason}`)
|
||||
// If response does not contain any timestamp, something is wrong
|
||||
if (!/\[\d+:\d+\.\d+\]/.test(responseText)) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// import { log } from 'console';
|
||||
import { getSongMeta, listMyPlaylists, listRecPlaylists, parseBrief } from '../lib/server/songs';
|
||||
import { listMyPlaylists, listRecPlaylists } from '../lib/server/songs';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, parent }) => {
|
||||
let last = parseBrief(await getSongMeta(25723366))
|
||||
let last = undefined
|
||||
const { user } = await parent()
|
||||
let myPlaylists = await listMyPlaylists(user)
|
||||
let recPlaylists = await listRecPlaylists()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { getSession } from '$lib/server/neteaseImport';
|
||||
import { getSession } from '$lib/server/songs';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { startImport } from '$lib/server/neteaseImport';
|
||||
import { login } from '$lib/server/user';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { startImport } from '$lib/server/songs';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
const { link } = await request.json();
|
||||
@@ -11,7 +11,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
if (!user) throw error(401, 'Unauthorized');
|
||||
|
||||
try {
|
||||
return json(await startImport(link, user))
|
||||
return json(await startImport(link, user._id))
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
throw error(500, 'Failed to start import')
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
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 type { NeteaseSong } from "../../../shared/types"
|
||||
import { API } from "../../../lib/client"
|
||||
import ErrorDialog from "../../../components/status/ErrorDialog.svelte";
|
||||
|
||||
let link = $state('')
|
||||
|
||||
interface SongImportStatus {
|
||||
song: NeteaseSongBrief
|
||||
song: NeteaseSong
|
||||
status: 'importing' | 'success' | 'failed-not-japanese' | 'failed-unknown'
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
<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>
|
||||
<span class="m3-font-body-small mfg-on-surface-variant">{song.song.ar.map(a => a.name).join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getSongsFromPlaylist, listRecPlaylists } from "$lib/server/songs.ts";
|
||||
import { getPlaylist } from "$lib/server/songs.ts";
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => ({
|
||||
playlist: await getSongsFromPlaylist(params.id)
|
||||
playlist: await getPlaylist(params.id)
|
||||
})
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
|
||||
let { data }: PageProps = $props()
|
||||
|
||||
let meta = $derived(data.playlist.meta)
|
||||
let songs = $derived(data.playlist.songs)
|
||||
let meta = $derived(data.playlist)
|
||||
let songs = $derived(data.playlist.tracks)
|
||||
let isFavorite = $derived(data.user.data.myPlaylists?.includes(meta.id) ?? false)
|
||||
|
||||
async function toggleFavorite() {
|
||||
@@ -44,11 +44,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vbox gap-12px">
|
||||
<div class="hbox gap-12px items-end! h-48px p-content">
|
||||
<div class="m3-font-headline-small">歌曲列表</div>
|
||||
<div class="m3-font-label-small pb-3px">{songs.length} 首歌曲</div>
|
||||
</div>
|
||||
<div class="hbox gap-12px items-end! h-48px p-content">
|
||||
<div class="m3-font-headline-small">歌曲列表</div>
|
||||
<div class="m3-font-label-small pb-3px">{songs.length} 首歌曲</div>
|
||||
</div>
|
||||
<div class="vbox gap-12px mt-12px min-h-0 overflow-y-auto">
|
||||
{#each songs as song, index}
|
||||
<a href="/song/{song.id}" class="p-content">
|
||||
<SongInfo info={song} />
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { error } from '@sveltejs/kit'
|
||||
import { getResult } from '$lib/server/result'
|
||||
import type { PageServerLoad } from './$types'
|
||||
import { getLyricsProcessed, getSongMeta, parseBrief } from "$lib/server/songs.ts";
|
||||
import { getLyricsProcessed, getSongRaw } from "$lib/server/songs.ts";
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const result = await getResult(params.id)
|
||||
if (!result) throw error(404, 'Result not found')
|
||||
|
||||
const song = await getSongMeta(result.songId)
|
||||
const song = await getSongRaw(result.songId)
|
||||
|
||||
return {
|
||||
result: structuredClone(result),
|
||||
lrc: await getLyricsProcessed(result.songId),
|
||||
song, brief: parseBrief(song)
|
||||
song
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<AppBar title={data.brief.name} sub={artistAndAlbum(data.brief)} />
|
||||
<AppBar title={data.song.name} sub={artistAndAlbum(data.song)} />
|
||||
|
||||
<div class="vbox gap-16px p-content flex-1 overflow-y-auto">
|
||||
<div class="hbox gap-12px items-end! h-48px">
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { PageServerLoad } from './$types'
|
||||
import { getLyricsProcessed, getSongMeta, listRecPlaylists, parseBrief } from "$lib/server/songs.ts";
|
||||
import { getLyricsProcessed, getSongRaw, listRecPlaylists } from "$lib/server/songs.ts";
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const songId = +params.id
|
||||
const raw = await getSongMeta(songId)
|
||||
const brief = parseBrief(raw)
|
||||
const song = await getSongRaw(songId)
|
||||
const lrc = await getLyricsProcessed(songId)
|
||||
return { raw, brief, lrc }
|
||||
return { song, lrc }
|
||||
}
|
||||
@@ -146,16 +146,16 @@
|
||||
// Result is stored on the server and is fetched from a separate results page
|
||||
async function submitResult() {
|
||||
const res = await API.saveResult({
|
||||
songId: data.raw.id,
|
||||
songId: data.song.id,
|
||||
endTime: Date.now(),
|
||||
realTimeFactor: (Date.now() - startTime) / (data.raw.dt / 1000),
|
||||
realTimeFactor: (Date.now() - startTime) / (data.song.dt / 1000),
|
||||
totalTyped, totalRight, startTime, statsHistory
|
||||
})
|
||||
goto(`/results/${res.id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<AppBar title={data.brief.name} sub={artistAndAlbum(data.brief)}>
|
||||
<AppBar title={data.song.name} sub={artistAndAlbum(data.song)}>
|
||||
<MenuItem textIcon="あ" onclick={() => settings.isFuri = !settings.isFuri}>{settings.isFuri ? "隐藏" : "显示"}假名标注</MenuItem>
|
||||
<MenuItem textIcon="カ" onclick={() => settings.allKata = !settings.allKata}>{settings.allKata ? "恢复平假名" : "全部转换为片假名"}</MenuItem>
|
||||
<MenuItem icon="i-material-symbols:language-japanese-kana-rounded" onclick={() => settings.showRomaji = !settings.showRomaji}>{settings.showRomaji ? "隐藏罗马音" : "显示罗马音"}</MenuItem>
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
import type { NeteaseSongBrief } from "./types";
|
||||
import type { NeteaseSong } from "./types";
|
||||
|
||||
export const artistAndAlbum = (song: NeteaseSongBrief) => `${song.artists.map(it => it.name).join(', ')} - ${song.album}`
|
||||
export const artistAndAlbum = (song: NeteaseSong) => `${song.ar.map(it => it.name).join(', ')} - ${song.al.name}`
|
||||
+18
-5
@@ -10,13 +10,26 @@ export interface Song {
|
||||
lyrics: LyricLine[]
|
||||
}
|
||||
|
||||
export interface NeteaseSongBrief {
|
||||
export interface NeteaseSong {
|
||||
id: number
|
||||
name: string
|
||||
album: string
|
||||
albumId: number
|
||||
albumPic: string
|
||||
artists: { id: number, name: string }[]
|
||||
al: {
|
||||
id: number
|
||||
name: string
|
||||
picUrl: string
|
||||
}
|
||||
ar: { id: number, name: string }[]
|
||||
dt: number
|
||||
}
|
||||
|
||||
export interface NeteasePlaylist {
|
||||
id: number
|
||||
name: string
|
||||
coverImgUrl: string
|
||||
creator: {
|
||||
nickname: string
|
||||
}
|
||||
tracks: NeteaseSong[]
|
||||
}
|
||||
|
||||
export interface ProcessedLyricLine {
|
||||
|
||||
Reference in New Issue
Block a user