feat: updates

This commit is contained in:
qier222 2023-01-28 11:54:57 +08:00
parent 7ce516877e
commit ccebe0a67a
No known key found for this signature in database
74 changed files with 56065 additions and 2810 deletions

View file

@ -5,12 +5,13 @@
const pkg = require('./package.json')
const electronVersion = pkg.devDependencies.electron.replaceAll('^', '')
const isDev = process.env.NODE_ENV === 'development'
module.exports = {
appId: 'app.r3play',
productName: pkg.productName,
copyright: 'Copyright © 2022 qier222',
asar: true,
asar: isDev ? true : false,
directories: {
output: 'release',
buildResources: 'build',
@ -119,6 +120,17 @@ module.exports = {
'!**/{npm-debug.log,yarn.lock,.yarn-integrity,.yarn-metadata.json,pnpm-lock.yaml}',
'!**/*.{map,debug.min.js}',
// copy prisma
{
from: './prisma',
to: 'main/prisma',
},
{
from: './prisma',
to: 'main',
filter: '*.prisma' // only copy prisma schema
},
{
from: './dist',
to: './main',

View file

@ -1,283 +0,0 @@
import { $ } from 'zx'
import store from './store'
import { ipcMain, BrowserWindow } from 'electron'
import { getNetworkInfo, sleep } from './utils'
import { spawn, ChildProcessWithoutNullStreams } from 'child_process'
import log from './log'
type Protocol = 'dmap' | 'mrp' | 'airplay' | 'companion' | 'raop'
interface Device {
name: string
address: string
identifier: string
services: { protocol: Protocol; port: number }[]
}
class Airplay {
devices: Device[] = []
pairProcess: ChildProcessWithoutNullStreams | null = null
window: BrowserWindow
constructor(window: BrowserWindow) {
log.debug('[airplay] ini')
this.window = window
this.initIpc()
}
async checkIsInstalled() {
const help = (await $`atvscript -h`).toString()
return String(help).includes('usage: atvscript')
}
async scanDevices(excludeThisDevice: boolean = true) {
if (!this.checkIsInstalled()) {
return {
result: 'failure',
error: 'pyatv is not installed',
}
}
let scanResult = null
try {
scanResult = await $`atvscript scan`
} catch (p: any) {
return {
result: 'failure',
error: p.stderr,
}
}
let json = null
try {
json = JSON.parse(scanResult.toString())
} catch (e) {
return {
result: 'failure',
error: String(e),
}
}
if (excludeThisDevice) {
const macAddress = getNetworkInfo()?.mac
if (macAddress) {
json.devices = json.devices.filter(
(device: Device) =>
device.identifier.toLowerCase() !== macAddress.toLowerCase()
)
}
}
if (json.result === 'success') {
this.devices = json.devices
}
return json
}
async pairDevice(deviceID: string, protocol: Protocol) {
this.pairProcess = spawn('atvremote', [
'--id',
deviceID,
'--protocol',
protocol,
'pair',
])
let paired = false
let done = false
this.pairProcess.stdout.on('data', (data: any) => {
console.log('stdout', String(data))
if (data.includes('You may now use these credentials:')) {
store.set(
`airplay.credentials.${deviceID}`,
String(data).split('credentials:')[1].trim()
)
paired = true
done = true
}
if (data.includes('Pairing failed')) {
paired = false
done = true
}
})
while (!done) {
console.log('not done yet')
await sleep(1000)
}
return paired
}
async enterPairPin(pin: string) {
if (!this.pairProcess) {
return false
}
this.pairProcess.stdin.write(`${pin}\n`)
return true
}
async playUrl(deviceID: string, url: string) {
log.debug(`[airplay] playUrl ${url}`)
const credentials: string = store.get(`airplay.credentials.${deviceID}`)
if (url.includes('127.0.0.1')) {
const ip = getNetworkInfo()?.address
if (ip) url = url.replace('127.0.0.1', ip)
}
try {
spawn('atvremote', [
'--id',
deviceID,
'--airplay-credentials',
credentials,
`play_url=${url}`,
])
} catch (p: any) {
return {
result: 'failure',
error: p.stderr,
}
}
}
async getPlaying(deviceID: string) {
if (!this.checkIsInstalled()) {
return {
result: 'failure',
error: 'pyatv is not installed',
}
}
const credentials = store.get(`airplay.credentials.${deviceID}`)
let playing = null
try {
playing =
await $`atvscript --id ${deviceID} --airplay-credentials=${credentials} playing`
} catch (p: any) {
return {
result: 'failure',
error: p.stderr,
}
}
let json = null
try {
json = JSON.parse(playing.toString())
} catch (e) {
return {
result: 'failure',
error: String(e),
}
}
return json
}
async play(deviceID: string) {
const credentials = store.get(`airplay.credentials.${deviceID}`)
try {
$`atvscript --id ${deviceID} --airplay-credentials ${credentials} play`
} catch (p: any) {
return {
result: 'failure',
error: p.stderr,
}
}
return {
result: 'success',
}
}
async pause(deviceID: string) {
const credentials = store.get(`airplay.credentials.${deviceID}`)
try {
$`atvscript --id ${deviceID} --airplay-credentials ${credentials} pause`
} catch (p: any) {
return {
result: 'failure',
error: p.stderr,
}
}
return {
result: 'success',
}
}
async playOrPause(deviceID: string) {
const credentials = store.get(`airplay.credentials.${deviceID}`)
try {
$`atvscript --id ${deviceID} --airplay-credentials ${credentials} play_pause`
} catch (p: any) {
return {
result: 'failure',
error: p.stderr,
}
}
return {
result: 'success',
}
}
async setProgress(deviceID: string, progress: number) {
const credentials = store.get(`airplay.credentials.${deviceID}`)
try {
$`atvremote --id ${deviceID} --airplay-credentials ${credentials} set_position=${progress}`
} catch (p: any) {
return {
result: 'failure',
error: p.stderr,
}
}
return {
result: 'success',
}
}
async pushUpdates(deviceID: string) {
const credentials = store.get(`airplay.credentials.${deviceID}`)
let updates = null
try {
updates = $`atvscript --id ${deviceID} --airplay-credentials ${credentials} push_updates`
} catch (p: any) {
return {
result: 'failure',
error: p.stderr,
}
}
for await (const chunk of updates.stdout) {
this.window.webContents.send('airplay-updates', chunk)
}
}
async initIpc() {
ipcMain.handle('airplay-scan-devices', () => {
return this.scanDevices()
})
ipcMain.handle('airplay-pair', async () => {
console.log('airplay-pair')
return this.pairDevice('58:D3:49:F0:C9:71', 'airplay')
})
ipcMain.handle('airplay-pair-enter-pin', async (e, pin) => {
return this.enterPairPin(pin)
})
ipcMain.handle('airplay-play-url', async (e, { deviceID, url }) => {
return this.playUrl(deviceID, url)
})
ipcMain.handle('airplay-get-playing', async (e, { deviceID }) => {
return this.getPlaying(deviceID)
})
}
}
export default Airplay

View file

@ -1,24 +1,28 @@
import fastify from 'fastify'
import fastifyStatic from '@fastify/static'
import path from 'path'
import fastifyCookie from '@fastify/cookie'
import fastifyMultipart from '@fastify/multipart'
import fastifyStatic from '@fastify/static'
import fastify from 'fastify'
import path from 'path'
import { isProd } from '../env'
import log from '../log'
import netease from './routes/netease'
import netease from './routes/netease/netease'
import appleMusic from './routes/r3play/appleMusic'
import audio from './routes/r3play/audio'
const server = fastify({
ignoreTrailingSlash: true,
})
server.register(fastifyCookie)
server.register(fastifyMultipart)
if (isProd) {
server.register(fastifyStatic, {
root: path.join(__dirname, '../web'),
})
}
server.register(netease, { prefix: '/netease' })
server.register(netease)
server.register(audio)
server.register(appleMusic)
const port = Number(

View file

@ -1,8 +1,9 @@
import { APIs } from '@/shared/CacheAPIs'
import log from '@/desktop/main/log'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { pathCase, snakeCase } from 'change-case'
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
import NeteaseCloudMusicApi from 'NeteaseCloudMusicApi'
import cache from '../../cache'
import cache from '../../../cache'
async function netease(fastify: FastifyInstance) {
const getHandler = (name: string, neteaseApi: (params: any) => any) => {
@ -10,11 +11,11 @@ async function netease(fastify: FastifyInstance) {
req: FastifyRequest<{ Querystring: { [key: string]: string } }>,
reply: FastifyReply
) => {
// Get track details from cache
if (name === APIs.Track) {
const cacheData = cache.get(name, req.query)
// // Get track details from cache
if (name === CacheAPIs.Track) {
const cacheData = await cache.get(name, req.query as any)
if (cacheData) {
return cache
return cacheData
}
}
@ -25,7 +26,8 @@ async function netease(fastify: FastifyInstance) {
cookie: req.cookies,
})
cache.set(name, result.body, req.query)
cache.set(name as CacheAPIs, result.body, req.query)
return reply.send(result.body)
} catch (error: any) {
if ([400, 301].includes(error.status)) {
@ -40,7 +42,9 @@ async function netease(fastify: FastifyInstance) {
Object.entries(NeteaseCloudMusicApi).forEach(([nameInSnakeCase, neteaseApi]: [string, any]) => {
// 例外
if (
['serveNcmApi', 'getModulesDefinitions', snakeCase(APIs.SongUrl)].includes(nameInSnakeCase)
['serveNcmApi', 'getModulesDefinitions', snakeCase(CacheAPIs.SongUrl)].includes(
nameInSnakeCase
)
) {
return
}
@ -48,11 +52,11 @@ async function netease(fastify: FastifyInstance) {
const name = pathCase(nameInSnakeCase)
const handler = getHandler(name, neteaseApi)
fastify.get(`/${name}`, handler)
fastify.post(`/${name}`, handler)
fastify.get(`/netease/${name}`, handler)
fastify.post(`/netease/${name}`, handler)
})
fastify.get('/', () => 'NeteaseCloudMusicApi')
fastify.get('/netease', () => 'NeteaseCloudMusicApi')
}
export default netease

View file

@ -0,0 +1,212 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
import NeteaseCloudMusicApi, { SoundQualityType } from 'NeteaseCloudMusicApi'
import prisma from '@/desktop/main/prisma'
import { app } from 'electron'
import log from '@/desktop/main/log'
import { appName } from '@/desktop/main/env'
import cache from '@/desktop/main/cache'
import fs from 'fs'
import youtube from '@/desktop/main/youtube'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { FetchTracksResponse } from '@/shared/api/Track'
const getAudioFromCache = async (id: number) => {
// get from cache
const cache = await prisma.audio.findUnique({ where: { id } })
if (!cache) return
const audioFileName = `${cache.id}-${cache.bitRate}.${cache.format}`
const isAudioFileExists = fs.existsSync(`${app.getPath('userData')}/audio_cache/${audioFileName}`)
if (!isAudioFileExists) return
log.debug(`[server] Audio cache hit ${id}`)
return {
data: [
{
source: cache.source,
id: cache.id,
url: `http://127.0.0.1:${
process.env.ELECTRON_WEB_SERVER_PORT
}/${appName.toLowerCase()}/audio/${audioFileName}`,
br: cache.bitRate,
size: 0,
md5: '',
code: 200,
expi: 0,
type: cache.format,
gain: 0,
fee: 8,
uf: null,
payed: 0,
flag: 4,
canExtend: false,
freeTrialInfo: null,
level: 'standard',
encodeType: cache.format,
freeTrialPrivilege: {
resConsumable: false,
userConsumable: false,
listenType: null,
},
freeTimeTrialPrivilege: {
resConsumable: false,
userConsumable: false,
type: 0,
remainTime: 0,
},
urlSource: 0,
},
],
code: 200,
}
}
const getAudioFromYouTube = async (id: number) => {
let fetchTrackResult: FetchTracksResponse | undefined = await cache.get(CacheAPIs.Track, {
ids: String(id),
})
if (!fetchTrackResult) {
log.info(`[audio] getAudioFromYouTube no fetchTrackResult, fetch from netease api`)
fetchTrackResult = (await NeteaseCloudMusicApi.song_detail({
ids: String(id),
})) as unknown as FetchTracksResponse
}
const track = fetchTrackResult?.songs?.[0]
if (!track) return
const data = await youtube.matchTrack(track.ar[0].name, track.name)
if (!data) return
return {
data: [
{
source: 'youtube',
id,
url: data.url,
br: data.bitRate,
size: 0,
md5: '',
code: 200,
expi: 0,
type: 'opus',
gain: 0,
fee: 8,
uf: null,
payed: 0,
flag: 4,
canExtend: false,
freeTrialInfo: null,
level: 'standard',
encodeType: 'opus',
freeTrialPrivilege: {
resConsumable: false,
userConsumable: false,
listenType: null,
},
freeTimeTrialPrivilege: {
resConsumable: false,
userConsumable: false,
type: 0,
remainTime: 0,
},
urlSource: 0,
r3play: {
youtube: data,
},
},
],
code: 200,
}
}
async function audio(fastify: FastifyInstance) {
// 劫持网易云的song/url api将url替换成缓存的音频文件url
fastify.get(
'/netease/song/url/v1',
async (
req: FastifyRequest<{ Querystring: { id: string | number; level: SoundQualityType } }>,
reply
) => {
const id = Number(req.query.id) || 0
if (!id || isNaN(id)) {
return reply.status(400).send({
code: 400,
msg: 'id is required or id is invalid',
})
}
const cache = await getAudioFromCache(id)
if (cache) {
return cache
}
const { body: fromNetease }: { body: any } = await NeteaseCloudMusicApi.song_url_v1({
...req.query,
cookie: req.cookies as unknown as any,
})
if (
fromNetease?.code === 200 &&
!fromNetease?.data?.[0]?.freeTrialInfo &&
fromNetease?.data?.[0]?.url
) {
reply.status(200).send(fromNetease)
return
}
const fromYoutube = getAudioFromYouTube(id)
if (fromYoutube) {
return fromYoutube
}
// 是试听歌曲就把url删掉
if (fromNetease?.data?.[0].freeTrialInfo) {
fromNetease.data[0].url = ''
}
reply.status(fromNetease?.code ?? 500).send(fromNetease)
}
)
// 获取缓存的音频数据
fastify.get(
`/${appName.toLowerCase()}/audio/:filename`,
(req: FastifyRequest<{ Params: { filename: string } }>, reply) => {
const filename = req.params.filename
cache.getAudio(filename, reply)
}
)
// 缓存音频数据
fastify.post(
`/${appName.toLowerCase()}/audio/:id`,
async (
req: FastifyRequest<{ Params: { id: string }; Querystring: { url: string } }>,
reply
) => {
const id = Number(req.params.id)
const { url } = req.query
if (isNaN(id)) {
return reply.status(400).send({ error: 'Invalid param id' })
}
if (!url) {
return reply.status(400).send({ error: 'Invalid query url' })
}
const data = await req.file()
if (!data?.file) {
return reply.status(400).send({ error: 'No file' })
}
try {
await cache.setAudio(await data.toBuffer(), { id, url })
reply.status(200).send('Audio cached!')
} catch (error) {
reply.status(500).send({ error })
}
}
)
}
export default audio

View file

@ -1,158 +1,149 @@
import { db, Tables } from './db'
import type { FetchTracksResponse } from '@/shared/api/Track'
import prisma from './prisma'
import { app } from 'electron'
import { Request, Response } from 'express'
import log from './log'
import fs from 'fs'
import * as musicMetadata from 'music-metadata'
import { APIs, APIsParams } from '@/shared/CacheAPIs'
import { TablesStructures } from './db'
import { CacheAPIs, CacheAPIsParams, CacheAPIsResponse } from '@/shared/CacheAPIs'
import { FastifyReply } from 'fastify'
class Cache {
constructor() {
//
}
async set(api: string, data: any, query: any = {}) {
async set<T extends CacheAPIs>(
api: T,
data: CacheAPIsResponse[T],
query: { [key: string]: string } = {}
) {
if (!data) return
switch (api) {
case APIs.UserPlaylist:
case APIs.UserAccount:
case APIs.Personalized:
case APIs.RecommendResource:
case APIs.UserAlbums:
case APIs.UserArtists:
case APIs.ListenedRecords:
case APIs.Likelist: {
if (!data) return
db.upsert(Tables.AccountData, {
id: api,
json: JSON.stringify(data),
updatedAt: Date.now(),
})
case CacheAPIs.UserPlaylist:
case CacheAPIs.UserAccount:
case CacheAPIs.Personalized:
case CacheAPIs.RecommendResource:
case CacheAPIs.UserAlbums:
case CacheAPIs.UserArtists:
case CacheAPIs.ListenedRecords:
case CacheAPIs.Likelist: {
const id = api
const row = { id, json: JSON.stringify(data) }
await prisma.accountData.upsert({ where: { id }, create: row, update: row })
break
}
case APIs.Track: {
const res = data as FetchTracksResponse
case CacheAPIs.Track: {
const res = data as CacheAPIsResponse[CacheAPIs.Track]
if (!res.songs) return
const tracks = res.songs.map(t => ({
id: t.id,
json: JSON.stringify(t),
updatedAt: Date.now(),
}))
db.upsertMany(Tables.Track, tracks)
break
}
case APIs.Album: {
if (!data.album) return
data.album.songs = data.songs
db.upsert(Tables.Album, {
id: data.album.id,
json: JSON.stringify(data.album),
updatedAt: Date.now(),
})
break
}
case APIs.Playlist: {
if (!data.playlist) return
db.upsert(Tables.Playlist, {
id: data.playlist.id,
json: JSON.stringify(data),
updatedAt: Date.now(),
})
break
}
case APIs.Artist: {
if (!data.artist) return
db.upsert(Tables.Artist, {
id: data.artist.id,
json: JSON.stringify(data),
updatedAt: Date.now(),
})
break
}
case APIs.ArtistAlbum: {
if (!data.hotAlbums) return
db.createMany(
Tables.Album,
data.hotAlbums.map((a: Album) => ({
id: a.id,
json: JSON.stringify(a),
updatedAt: Date.now(),
}))
await Promise.all(
res.songs.map(t => {
const id = t.id
const row = { id, json: JSON.stringify(t) }
return prisma.track.upsert({ where: { id }, create: row, update: row })
})
)
const modifiedData = {
...data,
hotAlbums: data.hotAlbums.map((a: Album) => a.id),
}
db.upsert(Tables.ArtistAlbum, {
id: data.artist.id,
json: JSON.stringify(modifiedData),
updatedAt: Date.now(),
})
break
}
case APIs.Lyric: {
case CacheAPIs.Album: {
const res = data as CacheAPIsResponse[CacheAPIs.Album]
if (!res.album) return
res.album.songs = data.songs
const id = data.album.id
const row = { id, json: JSON.stringify(data.album) }
await prisma.album.upsert({ where: { id }, update: row, create: row })
break
}
case CacheAPIs.Playlist: {
if (!data.playlist) return
const id = data.playlist.id
const row = { id, json: JSON.stringify(data) }
await prisma.playlist.upsert({ where: { id }, update: row, create: row })
break
}
case CacheAPIs.Artist: {
if (!data.artist) return
const id = data.artist.id
const row = { id, json: JSON.stringify(data) }
await prisma.artist.upsert({ where: { id }, update: row, create: row })
break
}
case CacheAPIs.ArtistAlbum: {
const res = data as CacheAPIsResponse[CacheAPIs.ArtistAlbum]
if (!res.hotAlbums) return
const id = data.artist.id
const row = { id, hotAlbums: res.hotAlbums.map(a => a.id).join(',') }
await prisma.artistAlbum.upsert({ where: { id }, update: row, create: row })
await Promise.all(
res.hotAlbums.map(async album => {
const id = album.id
const existAlbum = await prisma.album.findUnique({ where: { id } })
if (!existAlbum) {
await prisma.album.create({ data: { id, json: JSON.stringify(album) } })
}
})
)
break
}
case CacheAPIs.Lyric: {
if (!data.lrc) return
db.upsert(Tables.Lyric, {
id: query.id,
json: JSON.stringify(data),
updatedAt: Date.now(),
})
const id = Number(query.id)
const row = { id, json: JSON.stringify(data) }
await prisma.lyrics.upsert({ where: { id }, update: row, create: row })
break
}
case APIs.CoverColor: {
if (!data.id || !data.color) return
if (/^#([a-fA-F0-9]){3}$|[a-fA-F0-9]{6}$/.test(data.color) === false) {
return
}
db.upsert(Tables.CoverColor, {
id: data.id,
color: data.color,
queriedAt: Date.now(),
})
break
}
case APIs.AppleMusicAlbum: {
// case CacheAPIs.CoverColor: {
// if (!data.id || !data.color) return
// if (/^#([a-fA-F0-9]){3}$|[a-fA-F0-9]{6}$/.test(data.color) === false) {
// return
// }
// db.upsert(Tables.CoverColor, {
// id: data.id,
// color: data.color,
// queriedAt: Date.now(),
// })
// break
// }
case CacheAPIs.AppleMusicAlbum: {
if (!data.id) return
db.upsert(Tables.AppleMusicAlbum, {
id: data.id,
json: data.album ? JSON.stringify(data.album) : 'no',
updatedAt: Date.now(),
})
const id = data.id
const row = { id, json: JSON.stringify(data) }
await prisma.appleMusicAlbum.upsert({ where: { id }, update: row, create: row })
break
}
case APIs.AppleMusicArtist: {
case CacheAPIs.AppleMusicArtist: {
if (!data) return
db.upsert(Tables.AppleMusicArtist, {
id: data.id,
json: data.artist ? JSON.stringify(data.artist) : 'no',
updatedAt: Date.now(),
})
const id = data.id
const row = { id, json: JSON.stringify(data) }
await prisma.artist.upsert({ where: { id }, update: row, create: row })
break
}
}
}
get<T extends keyof APIsParams>(api: T, params: any): any {
async get<T extends CacheAPIs>(
api: T,
query: CacheAPIsParams[T]
): Promise<CacheAPIsResponse[T] | undefined> {
switch (api) {
case APIs.UserPlaylist:
case APIs.UserAccount:
case APIs.Personalized:
case APIs.RecommendResource:
case APIs.UserArtists:
case APIs.ListenedRecords:
case APIs.Likelist: {
const data = db.find(Tables.AccountData, api)
case CacheAPIs.UserPlaylist:
case CacheAPIs.UserAccount:
case CacheAPIs.Personalized:
case CacheAPIs.RecommendResource:
case CacheAPIs.UserArtists:
case CacheAPIs.ListenedRecords:
case CacheAPIs.Likelist: {
const data = await prisma.accountData.findUnique({ where: { id: api } })
if (data?.json) return JSON.parse(data.json)
break
}
case APIs.Track: {
const ids: number[] = params?.ids.split(',').map((id: string) => Number(id))
case CacheAPIs.Track: {
const typedQuery = query as CacheAPIsParams[CacheAPIs.Track]
const ids: number[] = typedQuery?.ids.split(',').map((id: string) => Number(id))
if (ids.length === 0) return
if (ids.includes(NaN)) return
const tracksRaw = db.findMany(Tables.Track, ids)
const tracksRaw = await prisma.track.findMany({ where: { id: { in: ids } } })
if (tracksRaw.length !== ids.length) {
return
@ -167,9 +158,11 @@ class Cache {
privileges: {},
}
}
case APIs.Album: {
if (isNaN(Number(params?.id))) return
const data = db.find(Tables.Album, params.id)
case CacheAPIs.Album: {
const typedQuery = query as CacheAPIsParams[CacheAPIs.Album]
const id = Number(typedQuery?.id)
if (isNaN(id)) return
const data = await prisma.album.findUnique({ where: { id } })
if (data?.json)
return {
resourceState: true,
@ -179,99 +172,94 @@ class Cache {
}
break
}
case APIs.Playlist: {
if (isNaN(Number(params?.id))) return
const data = db.find(Tables.Playlist, params.id)
case CacheAPIs.Playlist: {
const typedQuery = query as CacheAPIsParams[CacheAPIs.Playlist]
const id = Number(typedQuery?.id)
if (isNaN(id)) return
const data = await prisma.playlist.findUnique({ where: { id } })
if (data?.json) return JSON.parse(data.json)
break
}
case APIs.Artist: {
if (isNaN(Number(params?.id))) return
const data = db.find(Tables.Artist, params.id)
const fromAppleData = db.find(Tables.AppleMusicArtist, params.id)
const fromApple = fromAppleData?.json && JSON.parse(fromAppleData.json)
const fromNetease = data?.json && JSON.parse(data.json)
if (fromNetease && fromApple && fromApple !== 'no') {
fromNetease.artist.img1v1Url = fromApple.attributes.artwork.url
fromNetease.artist.briefDesc = fromApple.attributes.artistBio
}
return fromNetease ? fromNetease : undefined
}
case APIs.ArtistAlbum: {
if (isNaN(Number(params?.id))) return
const artistAlbumsRaw = db.find(Tables.ArtistAlbum, params.id)
if (!artistAlbumsRaw?.json) return
const artistAlbums = JSON.parse(artistAlbumsRaw.json)
const albumsRaw = db.findMany(Tables.Album, artistAlbums.hotAlbums)
if (albumsRaw.length !== artistAlbums.hotAlbums.length) return
const albums = albumsRaw.map(a => JSON.parse(a.json))
artistAlbums.hotAlbums = artistAlbums.hotAlbums.map((id: number) =>
albums.find(a => a.id === id)
)
return artistAlbums
}
case APIs.Lyric: {
if (isNaN(Number(params?.id))) return
const data = db.find(Tables.Lyric, params.id)
case CacheAPIs.Artist: {
const typedQuery = query as CacheAPIsParams[CacheAPIs.Artist]
const id = Number(typedQuery?.id)
if (isNaN(id)) return
const data = await prisma.artist.findUnique({ where: { id } })
if (data?.json) return JSON.parse(data.json)
break
}
case APIs.CoverColor: {
if (isNaN(Number(params?.id))) return
return db.find(Tables.CoverColor, params.id)?.color
}
case APIs.Artists: {
if (!params.ids?.length) return
const artists = db.findMany(Tables.Artist, params.ids)
if (artists.length !== params.ids.length) return
const result = artists.map(a => JSON.parse(a.json))
result.sort((a, b) => {
const indexA: number = params.ids.indexOf(a.artist.id)
const indexB: number = params.ids.indexOf(b.artist.id)
return indexA - indexB
case CacheAPIs.ArtistAlbum: {
const typedQuery = query as CacheAPIsParams[CacheAPIs.ArtistAlbum]
const id = Number(typedQuery?.id)
if (isNaN(id)) return
const artistAlbums = await prisma.artistAlbum.findUnique({ where: { id } })
if (!artistAlbums?.hotAlbums) return
const ids = artistAlbums.hotAlbums.split(',').map(Number)
const albumsRaw = await prisma.album.findMany({
where: { id: { in: ids } },
})
return result
if (albumsRaw.length !== ids.length) return
const albums = albumsRaw.map(a => JSON.parse(a.json))
return {
hotAlbums: ids.map((id: number) => albums.find(a => a.id === id)),
}
}
case APIs.AppleMusicAlbum: {
if (isNaN(Number(params?.id))) return
const data = db.find(Tables.AppleMusicAlbum, params.id)
if (data?.json && data.json !== 'no') return JSON.parse(data.json)
case CacheAPIs.Lyric: {
const typedQuery = query as CacheAPIsParams[CacheAPIs.Lyric]
const id = Number(typedQuery?.id)
if (isNaN(id)) return
const data = await prisma.lyrics.findUnique({ where: { id } })
if (data?.json) return JSON.parse(data.json)
break
}
case APIs.AppleMusicArtist: {
if (isNaN(Number(params?.id))) return
const data = db.find(Tables.AppleMusicArtist, params.id)
if (data?.json && data.json !== 'no') return JSON.parse(data.json)
case CacheAPIs.CoverColor: {
// if (isNaN(Number(params?.id))) return
// return db.find(Tables.CoverColor, params.id)?.color
}
case CacheAPIs.AppleMusicAlbum: {
const typedQuery = query as CacheAPIsParams[CacheAPIs.AppleMusicAlbum]
const id = Number(typedQuery?.id)
if (isNaN(id)) return
const data = await prisma.appleMusicAlbum.findUnique({ where: { id } })
if (data?.json) return JSON.parse(data.json)
break
}
case CacheAPIs.AppleMusicArtist: {
const typedQuery = query as CacheAPIsParams[CacheAPIs.AppleMusicArtist]
const id = Number(typedQuery?.id)
if (isNaN(id)) return
const data = await prisma.appleMusicArtist.findUnique({ where: { id } })
if (data?.json) return JSON.parse(data.json)
break
}
}
return
}
getAudio(fileName: string, res: Response) {
if (!fileName) {
return res.status(400).send({ error: 'No filename provided' })
getAudio(filename: string, reply: FastifyReply) {
if (!filename) {
return reply.status(400).send({ error: 'No filename provided' })
}
const id = Number(fileName.split('-')[0])
const id = Number(filename.split('-')[0])
try {
const path = `${app.getPath('userData')}/audio_cache/${fileName}`
const path = `${app.getPath('userData')}/audio_cache/${filename}`
const audio = fs.readFileSync(path)
if (audio.byteLength === 0) {
db.delete(Tables.Audio, id)
prisma.audio.delete({ where: { id } })
fs.unlinkSync(path)
return res.status(404).send({ error: 'Audio not found' })
return reply.status(404).send({ error: 'Audio not found' })
}
res
reply
.status(206)
.setHeader('Accept-Ranges', 'bytes')
.setHeader('Connection', 'keep-alive')
.setHeader('Content-Range', `bytes 0-${audio.byteLength - 1}/${audio.byteLength}`)
.header('Accept-Ranges', 'bytes')
.header('Connection', 'keep-alive')
.header('Content-Range', `bytes 0-${audio.byteLength - 1}/${audio.byteLength}`)
.send(audio)
} catch (error) {
res.status(500).send({ error })
reply.status(500).send({ error })
}
}
@ -285,8 +273,8 @@ class Cache {
}
const meta = await musicMetadata.parseBuffer(buffer)
const br = meta?.format?.codec === 'OPUS' ? 165000 : meta.format.bitrate ?? 0
const type =
const bitRate = (meta?.format?.codec === 'OPUS' ? 165000 : meta.format.bitrate ?? 0) / 1000
const format =
{
'MPEG 1 Layer 3': 'mp3',
'Ogg Vorbis': 'ogg',
@ -295,29 +283,23 @@ class Cache {
OPUS: 'opus',
}[meta.format.codec ?? ''] ?? 'unknown'
let source: TablesStructures[Tables.Audio]['source'] = 'unknown'
let source = 'unknown'
if (url.includes('googlevideo.com')) source = 'youtube'
if (url.includes('126.net')) source = 'netease'
if (url.includes('migu.cn')) source = 'migu'
if (url.includes('kuwo.cn')) source = 'kuwo'
if (url.includes('bilivideo.com')) source = 'bilibili'
// TODO: missing kugou qq joox
fs.writeFile(`${path}/${id}-${br}.${type}`, buffer, error => {
fs.writeFile(`${path}/${id}-${bitRate}.${format}`, buffer, async error => {
if (error) {
return log.error(`[cache] cacheAudio failed: ${error}`)
}
log.info(`Audio file ${id}-${br}.${type} cached!`)
db.upsert(Tables.Audio, {
id,
br,
type: type as TablesStructures[Tables.Audio]['type'],
source,
queriedAt: Date.now(),
const row = { id, bitRate, format, source }
await prisma.audio.upsert({
where: { id },
create: row,
update: row,
})
log.info(`[cache] cacheAudio ${id}-${br}.${type}`)
log.info(`Audio file ${id}-${bitRate}.${format} cached!`)
})
}
}

View file

@ -1,342 +0,0 @@
import { db, Tables } from './db'
import type { FetchTracksResponse } from '@/shared/api/Track'
import { app } from 'electron'
import { Request, Response } from 'express'
import log from './log'
import fs from 'fs'
import * as musicMetadata from 'music-metadata'
import { APIs, APIsParams, APIsResponse } from '@/shared/CacheAPIs'
import { TablesStructures } from './db'
class Cache {
constructor() {
//
}
async set(api: string, data: any, query: any = {}) {
switch (api) {
case APIs.UserPlaylist:
case APIs.UserAccount:
case APIs.Personalized:
case APIs.RecommendResource:
case APIs.UserAlbums:
case APIs.UserArtists:
case APIs.ListenedRecords:
case APIs.Likelist: {
if (!data) return
db.upsert(Tables.AccountData, {
id: api,
json: JSON.stringify(data),
updatedAt: Date.now(),
})
break
}
case APIs.Track: {
const res = data as FetchTracksResponse
if (!res.songs) return
const tracks = res.songs.map(t => ({
id: t.id,
json: JSON.stringify(t),
updatedAt: Date.now(),
}))
db.upsertMany(Tables.Track, tracks)
break
}
case APIs.Album: {
if (!data.album) return
data.album.songs = data.songs
db.upsert(Tables.Album, {
id: data.album.id,
json: JSON.stringify(data.album),
updatedAt: Date.now(),
})
break
}
case APIs.Playlist: {
if (!data.playlist) return
db.upsert(Tables.Playlist, {
id: data.playlist.id,
json: JSON.stringify(data),
updatedAt: Date.now(),
})
break
}
case APIs.Artist: {
if (!data.artist) return
db.upsert(Tables.Artist, {
id: data.artist.id,
json: JSON.stringify(data),
updatedAt: Date.now(),
})
break
}
case APIs.ArtistAlbum: {
if (!data.hotAlbums) return
db.createMany(
Tables.Album,
data.hotAlbums.map((a: Album) => ({
id: a.id,
json: JSON.stringify(a),
updatedAt: Date.now(),
}))
)
const modifiedData = {
...data,
hotAlbums: data.hotAlbums.map((a: Album) => a.id),
}
db.upsert(Tables.ArtistAlbum, {
id: data.artist.id,
json: JSON.stringify(modifiedData),
updatedAt: Date.now(),
})
break
}
case APIs.Lyric: {
if (!data.lrc) return
db.upsert(Tables.Lyric, {
id: query.id,
json: JSON.stringify(data),
updatedAt: Date.now(),
})
break
}
case APIs.CoverColor: {
if (!data.id || !data.color) return
if (/^#([a-fA-F0-9]){3}$|[a-fA-F0-9]{6}$/.test(data.color) === false) {
return
}
db.upsert(Tables.CoverColor, {
id: data.id,
color: data.color,
queriedAt: Date.now(),
})
break
}
case APIs.AppleMusicAlbum: {
if (!data.id) return
db.upsert(Tables.AppleMusicAlbum, {
id: data.id,
json: data.album ? JSON.stringify(data.album) : 'no',
updatedAt: Date.now(),
})
break
}
case APIs.AppleMusicArtist: {
if (!data) return
db.upsert(Tables.AppleMusicArtist, {
id: data.id,
json: data.artist ? JSON.stringify(data.artist) : 'no',
updatedAt: Date.now(),
})
break
}
}
}
get<T extends keyof APIsParams>(api: T, params: any): any {
switch (api) {
case APIs.UserPlaylist:
case APIs.UserAccount:
case APIs.Personalized:
case APIs.RecommendResource:
case APIs.UserArtists:
case APIs.ListenedRecords:
case APIs.Likelist: {
const data = db.find(Tables.AccountData, api)
if (data?.json) return JSON.parse(data.json)
break
}
case APIs.Track: {
const ids: number[] = params?.ids
.split(',')
.map((id: string) => Number(id))
if (ids.length === 0) return
if (ids.includes(NaN)) return
const tracksRaw = db.findMany(Tables.Track, ids)
if (tracksRaw.length !== ids.length) {
return
}
const tracks = ids.map(id => {
const track = tracksRaw.find(t => t.id === Number(id)) as any
return JSON.parse(track.json)
})
return {
code: 200,
songs: tracks,
privileges: {},
}
}
case APIs.Album: {
if (isNaN(Number(params?.id))) return
const data = db.find(Tables.Album, params.id)
if (data?.json)
return {
resourceState: true,
songs: [],
code: 200,
album: JSON.parse(data.json),
}
break
}
case APIs.Playlist: {
if (isNaN(Number(params?.id))) return
const data = db.find(Tables.Playlist, params.id)
if (data?.json) return JSON.parse(data.json)
break
}
case APIs.Artist: {
if (isNaN(Number(params?.id))) return
const data = db.find(Tables.Artist, params.id)
const fromAppleData = db.find(Tables.AppleMusicArtist, params.id)
const fromApple = fromAppleData?.json && JSON.parse(fromAppleData.json)
const fromNetease = data?.json && JSON.parse(data.json)
if (fromNetease && fromApple && fromApple !== 'no') {
fromNetease.artist.img1v1Url = fromApple.attributes.artwork.url
fromNetease.artist.briefDesc = fromApple.attributes.artistBio
}
return fromNetease ? fromNetease : undefined
}
case APIs.ArtistAlbum: {
if (isNaN(Number(params?.id))) return
const artistAlbumsRaw = db.find(Tables.ArtistAlbum, params.id)
if (!artistAlbumsRaw?.json) return
const artistAlbums = JSON.parse(artistAlbumsRaw.json)
const albumsRaw = db.findMany(Tables.Album, artistAlbums.hotAlbums)
if (albumsRaw.length !== artistAlbums.hotAlbums.length) return
const albums = albumsRaw.map(a => JSON.parse(a.json))
artistAlbums.hotAlbums = artistAlbums.hotAlbums.map((id: number) =>
albums.find(a => a.id === id)
)
return artistAlbums
}
case APIs.Lyric: {
if (isNaN(Number(params?.id))) return
const data = db.find(Tables.Lyric, params.id)
if (data?.json) return JSON.parse(data.json)
break
}
case APIs.CoverColor: {
if (isNaN(Number(params?.id))) return
return db.find(Tables.CoverColor, params.id)?.color
}
case APIs.Artists: {
if (!params.ids?.length) return
const artists = db.findMany(Tables.Artist, params.ids)
if (artists.length !== params.ids.length) return
const result = artists.map(a => JSON.parse(a.json))
result.sort((a, b) => {
const indexA: number = params.ids.indexOf(a.artist.id)
const indexB: number = params.ids.indexOf(b.artist.id)
return indexA - indexB
})
return result
}
case APIs.AppleMusicAlbum: {
if (isNaN(Number(params?.id))) return
const data = db.find(Tables.AppleMusicAlbum, params.id)
if (data?.json && data.json !== 'no') return JSON.parse(data.json)
break
}
case APIs.AppleMusicArtist: {
if (isNaN(Number(params?.id))) return
const data = db.find(Tables.AppleMusicArtist, params.id)
if (data?.json && data.json !== 'no') return JSON.parse(data.json)
break
}
}
}
getForExpress(api: string, req: Request) {
// Get track detail cache
if (api === APIs.Track) {
const cache = this.get(api, req.query)
if (cache) {
log.debug(`[cache] Cache hit for ${req.path}`)
return cache
}
}
}
getAudio(fileName: string, res: Response) {
if (!fileName) {
return res.status(400).send({ error: 'No filename provided' })
}
const id = Number(fileName.split('-')[0])
try {
const path = `${app.getPath('userData')}/audio_cache/${fileName}`
const audio = fs.readFileSync(path)
if (audio.byteLength === 0) {
db.delete(Tables.Audio, id)
fs.unlinkSync(path)
return res.status(404).send({ error: 'Audio not found' })
}
res
.status(206)
.setHeader('Accept-Ranges', 'bytes')
.setHeader('Connection', 'keep-alive')
.setHeader(
'Content-Range',
`bytes 0-${audio.byteLength - 1}/${audio.byteLength}`
)
.send(audio)
} catch (error) {
res.status(500).send({ error })
}
}
async setAudio(buffer: Buffer, { id, url }: { id: number; url: string }) {
const path = `${app.getPath('userData')}/audio_cache`
try {
fs.statSync(path)
} catch (e) {
fs.mkdirSync(path)
}
const meta = await musicMetadata.parseBuffer(buffer)
const br =
meta?.format?.codec === 'OPUS' ? 165000 : meta.format.bitrate ?? 0
const type =
{
'MPEG 1 Layer 3': 'mp3',
'Ogg Vorbis': 'ogg',
AAC: 'm4a',
FLAC: 'flac',
OPUS: 'opus',
}[meta.format.codec ?? ''] ?? 'unknown'
let source: TablesStructures[Tables.Audio]['source'] = 'unknown'
if (url.includes('googlevideo.com')) source = 'youtube'
if (url.includes('126.net')) source = 'netease'
if (url.includes('migu.cn')) source = 'migu'
if (url.includes('kuwo.cn')) source = 'kuwo'
if (url.includes('bilivideo.com')) source = 'bilibili'
// TODO: missing kugou qq joox
fs.writeFile(`${path}/${id}-${br}.${type}`, buffer, error => {
if (error) {
return log.error(`[cache] cacheAudio failed: ${error}`)
}
log.info(`Audio file ${id}-${br}.${type} cached!`)
db.upsert(Tables.Audio, {
id,
br,
type: type as TablesStructures[Tables.Audio]['type'],
source,
queriedAt: Date.now(),
})
log.info(`[cache] cacheAudio ${id}-${br}.${type}`)
})
}
}
export default new Cache()

View file

@ -1,344 +0,0 @@
import db from './surrealdb'
import type { FetchTracksResponse } from '@/shared/api/Track'
import { app } from 'electron'
import { Request, Response } from 'express'
import log from './log'
import fs from 'fs'
import * as musicMetadata from 'music-metadata'
import { APIs, APIsParams } from '@/shared/CacheAPIs'
// import { TablesStructures } from './db'
class Cache {
constructor() {
//
}
async set(api: string, data: any, query: any = {}) {
switch (api) {
case APIs.UserPlaylist:
case APIs.UserAccount:
case APIs.Personalized:
case APIs.RecommendResource:
case APIs.UserAlbums:
case APIs.UserArtists:
case APIs.ListenedRecords:
case APIs.Likelist: {
if (!data) return
db.upsert('netease', 'accountData', api, {
json: JSON.stringify(data),
})
break
}
case APIs.Track: {
const res = data as FetchTracksResponse
if (!res.songs) return
const tracks = res.songs.map(t => ({
key: t.id,
data: {
json: JSON.stringify(t),
},
}))
db.upsertMany('netease', 'track', tracks)
break
}
case APIs.Album: {
if (!data.album) return
data.album.songs = data.song
db.upsert('netease', 'album', data.album.id, data.album)
break
}
case APIs.Playlist: {
if (!data.playlist) return
db.upsert('netease', 'playlist', data.playlist.id, {
json: JSON.stringify(data),
updatedAt: Date.now(),
})
break
}
case APIs.Artist: {
if (!data.artist) return
db.upsert('netease', 'artist', data.artist.id, {
json: JSON.stringify(data),
updatedAt: Date.now(),
})
break
}
case APIs.ArtistAlbum: {
if (!data.hotAlbums) return
db.upsertMany(
'netease',
'album',
data.hotAlbums.map((a: Album) => ({
key: a.id,
data: {
json: JSON.stringify(a),
updatedAt: Date.now(),
},
}))
)
const modifiedData = {
...data,
hotAlbums: data.hotAlbums.map((a: Album) => a.id),
}
db.upsert('netease', 'artistAlbums', data.artist.id, {
json: JSON.stringify(modifiedData),
updatedAt: Date.now(),
})
break
}
case APIs.Lyric: {
if (!data.lrc) return
db.upsert('netease', 'lyric', query.id, {
json: JSON.stringify(data),
updatedAt: Date.now(),
})
break
}
// case APIs.CoverColor: {
// if (!data.id || !data.color) return
// if (/^#([a-fA-F0-9]){3}$|[a-fA-F0-9]{6}$/.test(data.color) === false) {
// return
// }
// db.upsert(Tables.CoverColor, {
// id: data.id,
// color: data.color,
// queriedAt: Date.now(),
// })
// break
// }
case APIs.AppleMusicAlbum: {
if (!data.id) return
db.upsert('appleMusic', 'album', data.id, {
json: data.album ? JSON.stringify(data.album) : 'no',
// updatedAt: Date.now(),
})
break
}
case APIs.AppleMusicArtist: {
if (!data) return
db.upsert('appleMusic', 'artist', data.id, {
json: data.artist ? JSON.stringify(data.artist) : 'no',
// updatedAt: Date.now(),
})
break
}
}
}
async get<T extends keyof APIsParams>(api: T, params: any): Promise<any> {
switch (api) {
case APIs.UserPlaylist:
case APIs.UserAccount:
case APIs.Personalized:
case APIs.RecommendResource:
case APIs.UserArtists:
case APIs.ListenedRecords:
case APIs.Likelist: {
const data = await db.find('netease', 'accountData', api)
if (!data?.[0]?.json) return
return JSON.parse(data[0].json)
}
case APIs.Track: {
const ids: number[] = params?.ids
.split(',')
.map((id: string) => Number(id))
if (ids.length === 0) return
if (ids.includes(NaN)) return
const tracksRaw = await db.findMany('netease', 'track', ids)
if (!tracksRaw || tracksRaw.length !== ids.length) {
return
}
const tracks = ids.map(id => {
const track = tracksRaw.find(t => t.id === Number(id)) as any
return JSON.parse(track.json)
})
return {
code: 200,
songs: tracks,
privileges: {},
}
}
case APIs.Album: {
if (isNaN(Number(params?.id))) return
const data = await db.find('netease', 'album', params.id)
const json = data?.[0]?.json
if (!json) return
return {
resourceState: true,
songs: [],
code: 200,
album: JSON.parse(json),
}
}
case APIs.Playlist: {
if (isNaN(Number(params?.id))) return
const data = await db.find('netease', 'playlist', params.id)
if (!data?.[0]?.json) return
return JSON.parse(data[0].json)
}
case APIs.Artist: {
if (isNaN(Number(params?.id))) return
const data = await db.find('netease', 'artist', params.id)
const fromAppleData = await db.find('appleMusic', 'artist', params.id)
const fromApple = fromAppleData?.json && JSON.parse(fromAppleData.json)
const fromNetease = data?.json && JSON.parse(data.json)
if (fromNetease && fromApple && fromApple !== 'no') {
fromNetease.artist.img1v1Url = fromApple.attributes.artwork.url
fromNetease.artist.briefDesc = fromApple.attributes.artistBio
}
return fromNetease || undefined
}
case APIs.ArtistAlbum: {
if (isNaN(Number(params?.id))) return
const artistAlbumsRaw = await db.find(
'netease',
'artistAlbums',
params.id
)
if (!artistAlbumsRaw?.json) return
const artistAlbums = JSON.parse(artistAlbumsRaw.json)
const albumsRaw = await db.findMany(
'netease',
'album',
artistAlbums.hotAlbums
)
if (albumsRaw?.length !== artistAlbums.hotAlbums.length) return
const albums = albumsRaw.map(a => JSON.parse(a.json))
artistAlbums.hotAlbums = artistAlbums.hotAlbums.map((id: number) =>
albums.find(a => a.id === id)
)
return artistAlbums
}
case APIs.Lyric: {
if (isNaN(Number(params?.id))) return
const data = await db.find('netease', 'lyric', params.id)
if (data?.json) return JSON.parse(data.json)
return
}
// case APIs.CoverColor: {
// if (isNaN(Number(params?.id))) return
// return await db.find(Tables.CoverColor, params.id)?.color
// }
case APIs.Artists: {
if (!params.ids?.length) return
const artists = await db.findMany('netease', 'artist', params.ids)
if (artists?.length !== params.ids.length) return
const result = artists?.map(a => JSON.parse(a.json))
result?.sort((a, b) => {
const indexA: number = params.ids.indexOf(a.artist.id)
const indexB: number = params.ids.indexOf(b.artist.id)
return indexA - indexB
})
return result
}
case APIs.AppleMusicAlbum: {
if (isNaN(Number(params?.id))) return
const data = await db.find('appleMusic', 'album', params.id)
if (data?.json && data.json !== 'no') return JSON.parse(data.json)
return
}
case APIs.AppleMusicArtist: {
if (isNaN(Number(params?.id))) return
const data = await db.find('appleMusic', 'artist', params.id)
if (data?.json && data.json !== 'no') return JSON.parse(data.json)
return
}
}
}
getForExpress(api: string, req: Request) {
// Get track detail cache
if (api === APIs.Track) {
const cache = this.get(api, req.query)
if (cache) {
log.debug(`[cache] Cache hit for ${req.path}`)
return cache
}
}
}
getAudio(fileName: string, res: Response) {
if (!fileName) {
return res.status(400).send({ error: 'No filename provided' })
}
const id = Number(fileName.split('-')[0])
try {
const path = `${app.getPath('userData')}/audio_cache/${fileName}`
const audio = fs.readFileSync(path)
// if audio file is empty, delete it
if (audio.byteLength === 0) {
db.delete('netease', 'audio', id)
fs.unlinkSync(path)
return res.status(404).send({ error: 'Audio not found' })
}
res
.status(206)
.setHeader('Accept-Ranges', 'bytes')
.setHeader('Connection', 'keep-alive')
.setHeader(
'Content-Range',
`bytes 0-${audio.byteLength - 1}/${audio.byteLength}`
)
.send(audio)
} catch (error) {
res.status(500).send({ error })
}
}
async setAudio(buffer: Buffer, { id, url }: { id: number; url: string }) {
const path = `${app.getPath('userData')}/audio_cache`
try {
fs.statSync(path)
} catch (e) {
fs.mkdirSync(path)
}
const meta = await musicMetadata.parseBuffer(buffer)
const br =
meta?.format?.codec === 'OPUS' ? 165000 : meta.format.bitrate ?? 0
const type =
{
'MPEG 1 Layer 3': 'mp3',
'Ogg Vorbis': 'ogg',
AAC: 'm4a',
FLAC: 'flac',
OPUS: 'opus',
}[meta.format.codec ?? ''] ?? 'unknown'
// let source: TablesStructures[Tables.Audio]['source'] = 'unknown'
let source = 'unknown'
if (url.includes('googlevideo.com')) source = 'youtube'
if (url.includes('126.net')) source = 'netease'
if (url.includes('migu.cn')) source = 'migu'
if (url.includes('kuwo.cn')) source = 'kuwo'
if (url.includes('bilivideo.com')) source = 'bilibili'
// TODO: missing kugou qq joox
fs.writeFile(`${path}/${id}-${br}.${type}`, buffer, error => {
if (error) {
return log.error(`[cache] cacheAudio failed: ${error}`)
}
log.info(`Audio file ${id}-${br}.${type} cached!`)
db.upsert('netease', 'audio', id, {
id,
br,
type,
source,
queriedAt: Date.now(),
})
log.info(`[cache] cacheAudio ${id}-${br}.${type}`)
})
}
}
export default new Cache()

View file

@ -1,235 +0,0 @@
import path from 'path'
import { app } from 'electron'
import fs from 'fs'
import SQLite3 from 'better-sqlite3'
import log from './log'
import { createFileIfNotExist, dirname } from './utils'
import { isProd } from './env'
import pkg from '../../../package.json'
import { compare, validate } from 'compare-versions'
import os from 'os'
export const enum Tables {
Track = 'Track',
Album = 'Album',
Artist = 'Artist',
Playlist = 'Playlist',
ArtistAlbum = 'ArtistAlbum',
Lyric = 'Lyric',
Audio = 'Audio',
AccountData = 'AccountData',
CoverColor = 'CoverColor',
AppData = 'AppData',
AppleMusicAlbum = 'AppleMusicAlbum',
AppleMusicArtist = 'AppleMusicArtist',
}
interface CommonTableStructure {
id: number
json: string
updatedAt: number
}
export interface TablesStructures {
[Tables.Track]: CommonTableStructure
[Tables.Album]: CommonTableStructure
[Tables.Artist]: CommonTableStructure
[Tables.Playlist]: CommonTableStructure
[Tables.ArtistAlbum]: CommonTableStructure
[Tables.Lyric]: CommonTableStructure
[Tables.AccountData]: {
id: string
json: string
updatedAt: number
}
[Tables.Audio]: {
id: number
br: number
type: 'mp3' | 'flac' | 'ogg' | 'wav' | 'm4a' | 'aac' | 'unknown' | 'opus'
source:
| 'unknown'
| 'netease'
| 'migu'
| 'kuwo'
| 'kugou'
| 'youtube'
| 'qq'
| 'bilibili'
| 'joox'
queriedAt: number
}
[Tables.CoverColor]: {
id: number
color: string
queriedAt: number
}
[Tables.AppData]: {
id: 'appVersion' | 'skippedVersion'
value: string
}
[Tables.AppleMusicAlbum]: CommonTableStructure
[Tables.AppleMusicArtist]: CommonTableStructure
}
type TableNames = keyof TablesStructures
const readSqlFile = (filename: string) => {
return fs.readFileSync(path.join(dirname, `./migrations/${filename}`), 'utf8')
}
class DB {
sqlite: SQLite3.Database
dbFilePath: string = path.resolve(app.getPath('userData'), './api_cache/db.sqlite')
constructor() {
log.info('[db] Initializing database...')
try {
createFileIfNotExist(this.dbFilePath)
this.sqlite = new SQLite3(this.dbFilePath, {
nativeBinding: this.getBinPath(),
})
this.sqlite.pragma('auto_vacuum = FULL')
this.initTables()
this.migrate()
log.info('[db] Database initialized.')
} catch (e) {
log.error('[db] Database initialization failed.')
log.error(e)
}
}
private getBinPath() {
console
const devBinPath = path.resolve(
app.getPath('userData'),
`../../bin/better_sqlite3_${os.platform}_${os.arch}.node`
)
const prodBinPaths = {
darwin: path.resolve(app.getPath('exe'), `../../Resources/bin/better_sqlite3.node`),
win32: path.resolve(app.getPath('exe'), `../resources/bin/better_sqlite3.node`),
linux: '',
}
return isProd
? prodBinPaths[os.platform as unknown as 'darwin' | 'win32' | 'linux']
: devBinPath
}
initTables() {
log.info('[db] Initializing database tables...')
const init = readSqlFile('init.sql')
this.sqlite.exec(init)
this.sqlite.pragma('journal_mode=WAL')
log.info('[db] Database tables initialized.')
}
migrate() {
log.info('[db] Migrating database..')
const key = 'appVersion'
const appVersion = this.find(Tables.AppData, key)
const updateAppVersionInDB = () => {
this.upsert(Tables.AppData, {
id: key,
value: pkg.version,
})
}
if (!appVersion?.value) {
updateAppVersionInDB()
return
}
const sqlFiles = fs.readdirSync(path.join(dirname, './migrations'))
sqlFiles.forEach((sqlFile: string) => {
const version = sqlFile.split('.').shift() || ''
if (!validate(version)) return
if (compare(version, pkg.version, '>')) {
const file = readSqlFile(sqlFile)
this.sqlite.exec(file)
}
})
updateAppVersionInDB()
log.info('[db] Database migrated.')
}
find<T extends TableNames>(
table: T,
key: TablesStructures[T]['id']
): TablesStructures[T] | undefined {
return this.sqlite.prepare(`SELECT * FROM ${table} WHERE id = ? LIMIT 1`).get(key)
}
findMany<T extends TableNames>(
table: T,
keys: TablesStructures[T]['id'][]
): TablesStructures[T][] {
const idsQuery = keys.map(key => `id = ${key}`).join(' OR ')
return this.sqlite.prepare(`SELECT * FROM ${table} WHERE ${idsQuery}`).all()
}
findAll<T extends TableNames>(table: T): TablesStructures[T][] {
return this.sqlite.prepare(`SELECT * FROM ${table}`).all()
}
create<T extends TableNames>(table: T, data: TablesStructures[T], skipWhenExist: boolean = true) {
if (skipWhenExist && db.find(table, data.id)) return
return this.sqlite.prepare(`INSERT INTO ${table} VALUES (?)`).run(data)
}
createMany<T extends TableNames>(
table: T,
data: TablesStructures[T][],
skipWhenExist: boolean = true
) {
const valuesQuery = Object.keys(data[0])
.map(key => `:${key}`)
.join(', ')
const insert = this.sqlite.prepare(
`INSERT ${skipWhenExist ? 'OR IGNORE' : ''} INTO ${table} VALUES (${valuesQuery})`
)
const insertMany = this.sqlite.transaction((rows: any[]) => {
rows.forEach((row: any) => insert.run(row))
})
insertMany(data)
}
upsert<T extends TableNames>(table: T, data: TablesStructures[T]) {
const valuesQuery = Object.keys(data)
.map(key => `:${key}`)
.join(', ')
return this.sqlite.prepare(`INSERT OR REPLACE INTO ${table} VALUES (${valuesQuery})`).run(data)
}
upsertMany<T extends TableNames>(table: T, data: TablesStructures[T][]) {
const valuesQuery = Object.keys(data[0])
.map(key => `:${key}`)
.join(', ')
const upsert = this.sqlite.prepare(`INSERT OR REPLACE INTO ${table} VALUES (${valuesQuery})`)
const upsertMany = this.sqlite.transaction((rows: any[]) => {
rows.forEach((row: any) => upsert.run(row))
})
upsertMany(data)
}
delete<T extends TableNames>(table: T, key: TablesStructures[T]['id']) {
return this.sqlite.prepare(`DELETE FROM ${table} WHERE id = ?`).run(key)
}
deleteMany<T extends TableNames>(table: T, keys: TablesStructures[T]['id'][]) {
const idsQuery = keys.map(key => `id = ${key}`).join(' OR ')
return this.sqlite.prepare(`DELETE FROM ${table} WHERE ${idsQuery}`).run()
}
truncate<T extends TableNames>(table: T) {
return this.sqlite.prepare(`DELETE FROM ${table}`).run()
}
vacuum() {
return this.sqlite.prepare('VACUUM').run()
}
}
export const db = new DB()

View file

@ -1,6 +1,5 @@
import './preload' // must be first
import './sentry'
// import './server'
import { BrowserWindow, BrowserWindowConstructorOptions, app, shell } from 'electron'
import { release } from 'os'
import { join } from 'path'
@ -39,11 +38,10 @@ class Main {
this.handleAppEvents()
this.handleWindowEvents()
this.createTray()
createMenu(this.win)
createMenu(this.win!)
this.createThumbar()
initIpcMain(this.win, this.tray, this.thumbar, store)
this.initDevTools()
// new Airplay(this.win)
})
}

View file

@ -6,7 +6,7 @@ import log from './log'
import fs from 'fs'
import Store from 'electron-store'
import { TypedElectronStore } from './store'
import { APIs } from '@/shared/CacheAPIs'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { YPMTray } from './tray'
import { Thumbar } from './windowsTaskbar'
import fastFolderSize from 'fast-folder-size'
@ -151,7 +151,7 @@ function initOtherIpcMain() {
*/
on(IpcChannels.CacheCoverColor, (event, args) => {
const { id, color } = args
cache.set(APIs.CoverColor, { id, color })
cache.set(CacheAPIs.CoverColor, { id, color })
})
/**

View file

@ -1,7 +1,7 @@
/** By default, it writes logs to the following locations:
* on Linux: ~/.config/{app name}/logs/{process type}.log
* on macOS: ~/Library/Logs/{app name}/{process type}.log
* on Windows: %USERPROFILE%\AppData\Roaming\{app name}\logs\{process type}.log
* on Linux: ~/.config/r3play/logs/main.log
* on macOS: ~/Library/Logs/r3play/main.log
* on Windows: %USERPROFILE%\AppData\Roaming\r3play\logs\main.log
* @see https://www.npmjs.com/package/electron-log
*/
@ -12,9 +12,7 @@ import { isDev } from './env'
Object.assign(console, log.functions)
log.variables.process = 'main'
if (log.transports.ipc) log.transports.ipc.level = false
log.transports.console.format = `${
isDev ? '' : pc.dim('{h}:{i}:{s}{scope} ')
}{level} {text}`
log.transports.console.format = `${isDev ? '' : pc.dim('{h}:{i}:{s}{scope} ')}{level} {text}`
log.transports.file.level = 'info'
log.info(

View file

@ -1,11 +1,7 @@
import log from './log'
import { app } from 'electron'
import { isDev } from './env'
import {
createDirIfNotExist,
portableUserDataPath,
devUserDataPath,
} from './utils'
import { createDirIfNotExist, portableUserDataPath, devUserDataPath, dirname } from './utils'
if (isDev) {
createDirIfNotExist(devUserDataPath)
@ -16,4 +12,6 @@ if (process.env.PORTABLE_EXECUTABLE_DIR) {
app.setPath('appData', portableUserDataPath)
}
log.info('[preload] dirname', dirname)
log.info(`[preload] userData path: ${app.getPath('userData')}`)

View file

@ -0,0 +1,84 @@
import { app } from 'electron'
import path from 'path'
import { PrismaClient } from '../prisma/client'
import { isDev } from './env'
import log from './log'
import { createFileIfNotExist, dirname, isFileExist } from './utils'
import fs from 'fs'
export const dbPath = path.join(app.getPath('userData'), 'r3play.db')
export const dbUrl = 'file://' + dbPath
const extraResourcesPath = app.getAppPath().replace('app.asar', '') // impacted by extraResources setting in electron-builder.yml
function getPlatformName(): string {
const isDarwin = process.platform === 'darwin'
if (isDarwin && process.arch === 'arm64') {
return process.platform + 'Arm64'
}
return process.platform
}
const platformName = getPlatformName()
export const platformToExecutables: any = {
win32: {
migrationEngine: 'node_modules/@prisma/engines/migration-engine-windows.exe',
queryEngine: 'node_modules/@prisma/engines/query_engine-windows.dll.node',
},
linux: {
migrationEngine: 'node_modules/@prisma/engines/migration-engine-debian-openssl-1.1.x',
queryEngine: 'node_modules/@prisma/engines/libquery_engine-debian-openssl-1.1.x.so.node',
},
darwin: {
migrationEngine: 'node_modules/@prisma/engines/migration-engine-darwin',
queryEngine: 'node_modules/@prisma/engines/libquery_engine-darwin.dylib.node',
},
darwinArm64: {
migrationEngine: 'node_modules/@prisma/engines/migration-engine-darwin-arm64',
queryEngine: 'node_modules/@prisma/engines/libquery_engine-darwin-arm64.dylib.node',
},
}
export const queryEnginePath = path.join(
extraResourcesPath,
platformToExecutables[platformName].queryEngine
)
log.info('[prisma] dbUrl', dbUrl)
// Hacky, but putting this here because otherwise at query time the Prisma client
// gives an error "Environment variable not found: DATABASE_URL" despite us passing
// the dbUrl into the prisma client constructor in datasources.db.url
process.env.DATABASE_URL = dbUrl
const isInitialized = isFileExist(dbPath)
if (!isInitialized) {
const from = isDev ? path.join(dirname, './prisma/r3play.db') : path.join(dirname, './r3play.db')
log.info(`[prisma] copy r3play.db file from ${from} to ${dbPath}`)
fs.copyFileSync(from, dbPath)
log.info('[prisma] Database tables initialized.')
} else {
log.info('[prisma] Database tables already initialized before.')
}
let prisma: PrismaClient = null
try {
prisma = new PrismaClient({
log: isDev ? ['info', 'warn', 'error'] : ['error'],
datasources: {
db: {
url: dbUrl,
},
},
// see https://github.com/prisma/prisma/discussions/5200
// @ts-expect-error internal prop
// __internal: {
// engine: {
// binaryPath: queryEnginePath,
// },
// },
})
log.info('[prisma] prisma initialized')
} catch (e) {
log.error('[prisma] failed to init prisma', e)
}
export default prisma

View file

@ -1,345 +0,0 @@
import { pathCase } from 'change-case'
import cookieParser from 'cookie-parser'
import express, { Request, Response } from 'express'
import log from './log'
import cache from './cache'
import fileUpload from 'express-fileupload'
import path from 'path'
import fs from 'fs'
// import { db, Tables } from './db'
import { app } from 'electron'
import type { FetchAudioSourceResponse } from '@/shared/api/Track'
import { APIs as CacheAPIs } from '@/shared/CacheAPIs'
import { appName, isProd } from './env'
import { APIs } from '@/shared/CacheAPIs'
import history from 'connect-history-api-fallback'
import { db, Tables } from './db'
import axios from 'axios'
class Server {
port = Number(
isProd
? process.env.ELECTRON_WEB_SERVER_PORT || 42710
: process.env.ELECTRON_DEV_NETEASE_API_PORT || 30001
)
app = express()
// eslint-disable-next-line @typescript-eslint/no-var-requires
netease = require('NeteaseCloudMusicApi') as any
constructor() {
log.info('[server] starting http server')
this.app.use(cookieParser())
this.app.use(fileUpload())
this.getAudioUrlHandler()
this.r3playApiHandler()
this.neteaseHandler()
this.cacheAudioHandler()
this.serveStaticForProduction()
this.listen()
}
r3playApiHandler() {
this.app.get(`/r3play/apple-music/:endpoint`, async (req: Request, res: Response) => {
log.debug(`[server] Handling request: ${req.path}`)
const { endpoint } = req.params
const { query } = req
axios
.get(`https://r3play.app/r3play/apple-music/${endpoint}`, {
params: query,
})
.then(response => {
console.log(response.data)
res.send(response.data)
})
.catch(error => {
console.log(error)
})
})
}
neteaseHandler() {
Object.entries(this.netease).forEach(([name, handler]: [string, any]) => {
// 例外处理
if (['serveNcmApi', 'getModulesDefinitions', APIs.SongUrl].includes(name)) {
return
}
name = pathCase(name)
const wrappedHandler = async (req: Request, res: Response) => {
log.debug(`[server] Handling request: ${req.path}`)
// Get from cache
const cacheData = await cache.getForExpress(name, req)
if (cacheData) return res.json(cacheData)
// Request netease api
try {
const result = await handler({
...req.query,
cookie: req.cookies,
})
cache.set(name, result.body, req.query)
return res.send(result.body)
} catch (error: any) {
if ([400, 301].includes(error.status)) {
return res.status(error.status).send(error.body)
}
return res.status(500)
}
}
this.app.get(`/netease/${name}`, wrappedHandler)
this.app.post(`/netease/${name}`, wrappedHandler)
})
}
serveStaticForProduction() {
if (isProd) {
this.app.use(history())
this.app.use(express.static(path.join(__dirname, '../web')))
}
}
getAudioUrlHandler() {
const getFromCache = (id: number) => {
// get from cache
const cache = db.find(Tables.Audio, id)
if (!cache) return
const audioFileName = `${cache.id}-${cache.br}.${cache.type}`
const isAudioFileExists = fs.existsSync(
`${app.getPath('userData')}/audio_cache/${audioFileName}`
)
if (!isAudioFileExists) return
log.debug(`[server] Audio cache hit for song/url`)
return {
data: [
{
source: cache.source,
id: cache.id,
url: `http://127.0.0.1:42710/${appName.toLowerCase()}/audio/${audioFileName}`,
br: cache.br,
size: 0,
md5: '',
code: 200,
expi: 0,
type: cache.type,
gain: 0,
fee: 8,
uf: null,
payed: 0,
flag: 4,
canExtend: false,
freeTrialInfo: null,
level: 'standard',
encodeType: cache.type,
freeTrialPrivilege: {
resConsumable: false,
userConsumable: false,
listenType: null,
},
freeTimeTrialPrivilege: {
resConsumable: false,
userConsumable: false,
type: 0,
remainTime: 0,
},
urlSource: 0,
},
],
code: 200,
}
}
const getFromNetease = async (req: Request): Promise<FetchAudioSourceResponse | undefined> => {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const getSongUrl = (require('NeteaseCloudMusicApi') as any).song_url
const result = await getSongUrl({ ...req.query, cookie: req.cookies })
return result.body
} catch (error: any) {
return
}
}
// const unmExecutor = new UNM.Executor()
const getFromUNM = async (id: number, req: Request) => {
log.debug('[server] Fetching audio url from UNM')
let track: Track = cache.get(CacheAPIs.Track, { ids: String(id) })?.songs?.[0]
if (!track) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const getSongDetail = (require('NeteaseCloudMusicApi') as any).song_detail
track = await getSongDetail({ ...req.query, cookie: req.cookies })
}
if (!track) return
const trackForUNM = {
id: String(track.id),
name: track.name,
duration: track.dt,
album: {
id: String(track.al.id),
name: track.al.name,
},
artists: [
...track.ar.map((a: Artist) => ({
id: String(a.id),
name: a.name,
})),
],
}
const sourceList = ['ytdl']
const context = {}
const matchedAudio = await unmExecutor.search(sourceList, trackForUNM, context)
const retrievedSong = await unmExecutor.retrieve(matchedAudio, context)
const source = retrievedSong.source === 'ytdl' ? 'youtube' : retrievedSong.source
if (retrievedSong.url) {
log.debug(
`[server] UMN match: ${matchedAudio.song?.name} (https://youtube.com/v/${matchedAudio.song?.id})`
)
return {
data: [
{
source,
id,
url: retrievedSong.url,
br: 128000,
size: 0,
md5: '',
code: 200,
expi: 0,
type: 'unknown',
gain: 0,
fee: 8,
uf: null,
payed: 0,
flag: 4,
canExtend: false,
freeTrialInfo: null,
level: 'standard',
encodeType: 'unknown',
freeTrialPrivilege: {
resConsumable: false,
userConsumable: false,
listenType: null,
},
freeTimeTrialPrivilege: {
resConsumable: false,
userConsumable: false,
type: 0,
remainTime: 0,
},
urlSource: 0,
unm: {
source,
song: matchedAudio.song,
},
},
],
code: 200,
}
}
}
const handler = async (req: Request, res: Response) => {
const id = Number(req.query.id) || 0
if (id === 0) {
return res.status(400).send({
code: 400,
msg: 'id is required or id is invalid',
})
}
try {
const fromCache = await getFromCache(id)
if (fromCache) {
res.status(200).send(fromCache)
return
}
} catch (error) {
log.error(`[server] getFromCache failed: ${String(error)}`)
}
const fromNetease = await getFromNetease(req)
if (
fromNetease?.code === 200 &&
!fromNetease?.data?.[0]?.freeTrialInfo &&
fromNetease?.data?.[0]?.url
) {
res.status(200).send(fromNetease)
return
}
// try {
// const fromUNM = await getFromUNM(id, req)
// if (fromUNM) {
// res.status(200).send(fromUNM)
// return
// }
// } catch (error) {
// log.error(`[server] getFromUNM failed: ${String(error)}`)
// }
if (fromNetease?.data?.[0].freeTrialInfo) {
fromNetease.data[0].url = ''
}
res.status(fromNetease?.code ?? 500).send(fromNetease)
}
this.app.get('/netease/song/url', handler)
}
cacheAudioHandler() {
this.app.get(
`/${appName.toLowerCase()}/audio/:filename`,
async (req: Request, res: Response) => {
cache.getAudio(req.params.filename, res)
}
)
this.app.post(`/${appName.toLowerCase()}/audio/:id`, async (req: Request, res: Response) => {
const id = Number(req.params.id)
const { url } = req.query
if (isNaN(id)) {
return res.status(400).send({ error: 'Invalid param id' })
}
if (!url) {
return res.status(400).send({ error: 'Invalid query url' })
}
if (!req.files || Object.keys(req.files).length === 0 || !req.files.file) {
return res.status(400).send('No audio were uploaded.')
}
if ('length' in req.files.file) {
return res.status(400).send('Only can upload one audio at a time.')
}
try {
await cache.setAudio(req.files.file.data, {
id,
url: String(req.query.url) || '',
})
res.status(200).send('Audio cached!')
} catch (error) {
res.status(500).send({ error })
}
})
}
listen() {
this.app.listen(this.port, '127.0.0.1', () => {
log.info(`[server] API server listening on port ${this.port}`)
})
}
}
export default new Server()

View file

@ -8,11 +8,6 @@ export interface TypedElectronStore {
y?: number
}
// settings: State['settings']
airplay: {
credentials: {
[key: string]: string
}
}
}
const store = new Store<TypedElectronStore>({
@ -22,9 +17,6 @@ const store = new Store<TypedElectronStore>({
height: 1024,
},
// settings: initialState.settings,
airplay: {
credentials: {},
},
},
})

View file

@ -1,301 +0,0 @@
import axios, { AxiosInstance } from 'axios'
import { app, ipcMain } from 'electron'
import path from 'path'
import { Get } from 'type-fest'
import { $, fs } from 'zx'
import log from './log'
import { flatten } from 'lodash'
// surreal start --bind 127.0.0.1:37421 --user user --pass pass --log trace file:///Users/max/Developer/GitHub/replay/tmp/UserData/api_cache/surreal
interface Databases {
appleMusic: {
artist: {
key: string
data: {
json: string
}
}
album: {
key: string
data: {
json: string
}
}
}
replay: {
appData: {
id: 'appVersion' | 'skippedVersion'
value: string
}
}
netease: {
track: {
id: number
json: string
updatedAt: number
}
artist: {
id: number
json: string
updatedAt: number
}
album: {
id: number
json: string
updatedAt: number
}
artistAlbums: {
id: number
json: string
updatedAt: number
}
lyric: {
id: number
json: string
updatedAt: number
}
playlist: {
id: number
json: string
updatedAt: number
}
accountData: {
id: string
json: string
updatedAt: number
}
audio: {
id: number
br: number
type: 'mp3' | 'flac' | 'ogg' | 'wav' | 'm4a' | 'aac' | 'unknown' | 'opus'
source:
| 'unknown'
| 'netease'
| 'migu'
| 'kuwo'
| 'kugou'
| 'youtube'
| 'qq'
| 'bilibili'
| 'joox'
queriedAt: number
}
}
}
interface SurrealSuccessResult<R> {
time: string
status: 'OK' | 'ERR'
result?: R[]
detail?: string
}
interface SurrealErrorResult {
code: 400
details: string
description: string
information: string
}
class Surreal {
private port = 37421
private username = 'user'
private password = 'pass'
private request: AxiosInstance
constructor() {
this.start()
this.request = axios.create({
baseURL: `http://127.0.0.1:${this.port}`,
timeout: 15000,
auth: {
username: this.username,
password: this.password,
},
headers: {
NS: 'replay',
Accept: 'application/json',
},
responseType: 'json',
})
}
getSurrealBinPath() {
return path.join(__dirname, `./binary/surreal`)
}
getDatabasePath() {
return path.resolve(app.getPath('userData'), './api_cache/surreal')
}
getKey(table: string, key: string) {
if (key.includes('/')) {
return `${table}:⟨${key}`
}
return `${table}:${key}`
}
async query<R>(
database: keyof Databases,
query: string
): Promise<R[] | undefined> {
type DBResponse =
| SurrealSuccessResult<R>
| Array<SurrealSuccessResult<R>>
| SurrealErrorResult
const result = await this.request
.post<DBResponse | undefined>('/sql', query, {
headers: { DB: database },
})
.catch(e => {
log.error(
`[surreal] Axios Error: ${e}, response: ${JSON.stringify(
e.response.data,
null,
2
)}`
)
})
if (!result?.data) {
log.error(`[surreal] No result`)
return []
}
const data = result.data
if (Array.isArray(data)) {
return flatten(data.map(item => item?.result).filter(Boolean) as R[][])
}
if ('status' in data) {
if (data.status === 'OK') {
return data.result
}
if (data.status === 'ERR') {
log.error(`[surreal] ${data.detail}`)
throw new Error(`[surreal] query error: ${data.detail}`)
}
}
if ('code' in data && data.code !== 400) {
throw new Error(`[surreal] query error: ${data.description}`)
}
throw new Error('[surreal] query error: unknown error')
}
async start() {
log.info(`[surreal] Starting surreal, listen on 127.0.0.1:${this.port}`)
await $`${this.getSurrealBinPath()} start --bind 127.0.0.1:${
this.port
} --user ${this.username} --pass ${
this.password
} --log warn file://${this.getDatabasePath()}`
}
async create<D extends keyof Databases, T extends keyof Databases[D]>(
database: D,
table: T,
key: Get<Databases[D][T], 'key'>,
data: Get<Databases[D][T], 'data'>
) {
const result = await this.query<Get<Databases[D][T], 'data'>>(
database,
`CREATE ${String(table)}:(${String(key)}) CONTENT ${JSON.stringify(data)}`
)
return result?.[0]
}
async upsert<D extends keyof Databases, T extends keyof Databases[D]>(
database: D,
table: T,
key: Get<Databases[D][T], 'key'>,
data: Get<Databases[D][T], 'data'>
) {
fs.writeFile(
'tmp.json',
`INSERT INTO ${String(table)} ${JSON.stringify({ ...data, id: key })}`
)
const result = await this.query<Get<Databases[D][T], 'data'>>(
database,
`INSERT INTO ${String(table)} ${JSON.stringify({ ...data, id: key })} `
)
return result?.[0]
}
upsertMany<D extends keyof Databases, T extends keyof Databases[D]>(
database: D,
table: T,
data: {
key: Get<Databases[D][T], 'key'>
data: Get<Databases[D][T], 'data'>
}[]
) {
const queries = data.map(query => {
return `INSERT INTO ${String(table)} ${JSON.stringify(query.data)};`
})
return this.query<Get<Databases[D][T], 'data'>>(database, queries.join(' '))
}
async find<D extends keyof Databases, T extends keyof Databases[D]>(
database: D,
table: T,
key: Get<Databases[D][T], 'key'>
) {
return this.query<Get<Databases[D][T], 'data'>>(
database,
`SELECT * FROM ${String(table)} WHERE id = "${this.getKey(
String(table),
String(key)
)}" LIMIT 1`
) as Promise<Get<Databases[D][T], 'data'>[]>
}
async findMany<D extends keyof Databases, T extends keyof Databases[D]>(
database: D,
table: T,
keys: Get<Databases[D][T], 'key'>[]
) {
const idsQuery = keys
.map(key => `id = "${this.getKey(String(table), String(key))}"`)
.join(' OR ')
return this.query<Get<Databases[D][T], 'data'>>(
database,
`SELECT * FROM ${String(table)} WHERE ${idsQuery} TIMEOUT 5s`
)
}
async delete<D extends keyof Databases, T extends keyof Databases[D]>(
database: D,
table: T,
key: Get<Databases[D][T], 'key'>
) {
try {
await this.query(
database,
`SELECT ${this.getKey(String(table), String(key))}`
)
return true
} catch (error) {
return false
}
}
async deleteTable<D extends keyof Databases, T extends keyof Databases[D]>(
database: D,
table: T
) {
try {
await this.query(database, `DELETE ${String(table)}`)
return true
} catch (error) {
return false
}
}
}
const surreal = new Surreal()
export default surreal

View file

@ -16,15 +16,19 @@ export const logsPath = {
win32: `%USERPROFILE%\\AppData\\Roaming\\${pkg.productName}\\logs`,
}[process.platform as 'darwin' | 'win32' | 'linux']
export const isFileExist = (file: string) => {
return fs.existsSync(file)
}
export const createDirIfNotExist = (dir: string) => {
if (!fs.existsSync(dir)) {
if (!isFileExist(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
}
export const createFileIfNotExist = (file: string) => {
createDirIfNotExist(path.dirname(file))
if (!fs.existsSync(file)) {
if (!isFileExist(file)) {
fs.writeFileSync(file, '')
}
}

View file

@ -0,0 +1,71 @@
import log from './log'
import youtube, { Scraper, Video } from '@yimura/scraper'
import ytdl from 'ytdl-core'
class YoutubeDownloader {
yt: Scraper
constructor() {
// @ts-ignore
this.yt = new youtube.default()
}
async search(keyword: string) {
const result = await this.yt.search(keyword)
return result?.videos
}
async matchTrack(artist: string, trackName: string) {
console.time('[youtube] search')
const videos = await this.search(`${artist} ${trackName} lyric audio`)
console.timeEnd('[youtube] search')
let video: Video | null = null
// 找官方频道最匹配的
// videos.forEach(v => {
// if (video) return
// const channelName = v.channel.name.toLowerCase()
// if (channelName !== artist.toLowerCase()) return
// const title = v.title.toLowerCase()
// if (!title.includes(trackName.toLowerCase())) return
// if (!title.includes('audio') && !title.includes('lyric')) return
// video = v
// })
// TODO:找时长误差不超过2秒的
// 最后方案选搜索的第一个
if (!video) {
video = videos[0]
}
console.time('[youtube] getInfo')
const info = await ytdl.getInfo('http://www.youtube.com/watch?v=' + video.id)
console.timeEnd('[youtube] getInfo')
let url = ''
let bitRate = 0
info.formats.forEach(video => {
if (
video.mimeType === `audio/webm; codecs="opus"` &&
video.bitrate &&
video.bitrate > bitRate
) {
url = video.url
bitRate = video.bitrate
}
})
const data = {
url,
bitRate,
title: info.videoDetails.title,
videoId: info.videoDetails.videoId,
duration: info.videoDetails.lengthSeconds,
channel: info.videoDetails.ownerChannelName,
}
log.info(`[youtube] matched `, data)
return data
}
}
const youtubeDownloader = new YoutubeDownloader()
export default youtubeDownloader

View file

@ -1,13 +1,120 @@
CREATE TABLE IF NOT EXISTS "AccountData" ("id" text NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "Album" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "ArtistAlbum" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "Artist" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "Lyric" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" integer NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "Playlist" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "Track" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
-- CreateTable
CREATE TABLE "AccountData" IF NOT EXISTS (
"id" TEXT NOT NULL PRIMARY KEY,
"json" TEXT NOT NULL,
"updatedAt" DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS "AppData" ("id" text NOT NULL,"value" text, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "CoverColor" ("id" integer NOT NULL,"color" text NOT NULL, "queriedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "Audio" ("id" integer NOT NULL,"br" int NOT NULL,"type" text NOT NULL,"source" text NOT NULL, "queriedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "AppleMusicArtist" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "AppleMusicAlbum" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
-- CreateTable
CREATE TABLE "AppData" IF NOT EXISTS (
"id" TEXT NOT NULL PRIMARY KEY,
"value" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "Track" IF NOT EXISTS (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"json" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Album" IF NOT EXISTS (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"json" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Artist" IF NOT EXISTS (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"json" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "ArtistAlbum" IF NOT EXISTS (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"hotAlbums" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Playlist" IF NOT EXISTS (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"json" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Audio" IF NOT EXISTS (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"bitRate" INTEGER NOT NULL,
"format" TEXT NOT NULL,
"source" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"queriedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "Lyrics" IF NOT EXISTS (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"json" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "AppleMusicAlbum" IF NOT EXISTS (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"json" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "AppleMusicArtist" IF NOT EXISTS (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"json" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "AccountData_id_key" ON "AccountData"("id");
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "AppData_id_key" ON "AppData"("id");
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "Track_id_key" ON "Track"("id");
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "Album_id_key" ON "Album"("id");
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "Artist_id_key" ON "Artist"("id");
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "ArtistAlbum_id_key" ON "ArtistAlbum"("id");
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "Playlist_id_key" ON "Playlist"("id");
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "Audio_id_key" ON "Audio"("id");
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "Lyrics_id_key" ON "Lyrics"("id");
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "AppleMusicAlbum_id_key" ON "AppleMusicAlbum"("id");
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "AppleMusicArtist_id_key" ON "AppleMusicArtist"("id");

View file

@ -14,7 +14,9 @@
"test:types": "tsc --noEmit --project ./tsconfig.json",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage"
"test:coverage": "vitest run --coverage",
"prisma:generate": "prisma generate",
"prisma:db-push": "prisma db push"
},
"engines": {
"node": "^14.13.1 || >=16.0.0"
@ -22,26 +24,22 @@
"dependencies": {
"@fastify/cookie": "^8.3.0",
"@fastify/http-proxy": "^8.4.0",
"@fastify/multipart": "^7.4.0",
"@prisma/client": "^4.8.1",
"@prisma/engines": "^4.9.0",
"@sentry/electron": "^3.0.7",
"NeteaseCloudMusicApi": "^4.8.7",
"better-sqlite3": "8.0.1",
"@yimura/scraper": "^1.2.4",
"NeteaseCloudMusicApi": "^4.8.9",
"change-case": "^4.1.2",
"compare-versions": "^4.1.3",
"connect-history-api-fallback": "^2.0.0",
"cookie-parser": "^1.4.6",
"electron-log": "^4.4.8",
"electron-store": "^8.1.0",
"express": "^4.18.2",
"fast-folder-size": "^1.7.1",
"pretty-bytes": "^6.0.0",
"type-fest": "^3.5.0",
"zx": "^7.1.1"
"prisma": "^4.8.1",
"ytdl-core": "^4.11.2"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.3",
"@types/cookie-parser": "^1.4.3",
"@types/express": "^4.17.15",
"@types/express-fileupload": "^1.2.3",
"@vitest/ui": "^0.20.3",
"axios": "^1.2.1",
"cross-env": "^7.0.3",
@ -52,7 +50,6 @@
"electron-rebuild": "^3.2.9",
"electron-releases": "^3.1171.0",
"esbuild": "^0.16.10",
"express-fileupload": "^1.4.0",
"minimist": "^1.2.7",
"music-metadata": "^8.1.0",
"open-cli": "^7.1.0",

View file

@ -0,0 +1,206 @@
Object.defineProperty(exports, "__esModule", { value: true });
const {
Decimal,
objectEnumValues,
makeStrictEnum
} = require('./runtime/index-browser')
const Prisma = {}
exports.Prisma = Prisma
/**
* Prisma Client JS version: 4.8.1
* Query Engine version: d6e67a83f971b175a593ccc12e15c4a757f93ffe
*/
Prisma.prismaVersion = {
client: "4.8.1",
engine: "d6e67a83f971b175a593ccc12e15c4a757f93ffe"
}
Prisma.PrismaClientKnownRequestError = () => {
throw new Error(`PrismaClientKnownRequestError is unable to be run in the browser.
In case this error is unexpected for you, please report it in https://github.com/prisma/prisma/issues`,
)};
Prisma.PrismaClientUnknownRequestError = () => {
throw new Error(`PrismaClientUnknownRequestError is unable to be run in the browser.
In case this error is unexpected for you, please report it in https://github.com/prisma/prisma/issues`,
)}
Prisma.PrismaClientRustPanicError = () => {
throw new Error(`PrismaClientRustPanicError is unable to be run in the browser.
In case this error is unexpected for you, please report it in https://github.com/prisma/prisma/issues`,
)}
Prisma.PrismaClientInitializationError = () => {
throw new Error(`PrismaClientInitializationError is unable to be run in the browser.
In case this error is unexpected for you, please report it in https://github.com/prisma/prisma/issues`,
)}
Prisma.PrismaClientValidationError = () => {
throw new Error(`PrismaClientValidationError is unable to be run in the browser.
In case this error is unexpected for you, please report it in https://github.com/prisma/prisma/issues`,
)}
Prisma.NotFoundError = () => {
throw new Error(`NotFoundError is unable to be run in the browser.
In case this error is unexpected for you, please report it in https://github.com/prisma/prisma/issues`,
)}
Prisma.Decimal = Decimal
/**
* Re-export of sql-template-tag
*/
Prisma.sql = () => {
throw new Error(`sqltag is unable to be run in the browser.
In case this error is unexpected for you, please report it in https://github.com/prisma/prisma/issues`,
)}
Prisma.empty = () => {
throw new Error(`empty is unable to be run in the browser.
In case this error is unexpected for you, please report it in https://github.com/prisma/prisma/issues`,
)}
Prisma.join = () => {
throw new Error(`join is unable to be run in the browser.
In case this error is unexpected for you, please report it in https://github.com/prisma/prisma/issues`,
)}
Prisma.raw = () => {
throw new Error(`raw is unable to be run in the browser.
In case this error is unexpected for you, please report it in https://github.com/prisma/prisma/issues`,
)}
Prisma.validator = () => (val) => val
/**
* Shorthand utilities for JSON filtering
*/
Prisma.DbNull = objectEnumValues.instances.DbNull
Prisma.JsonNull = objectEnumValues.instances.JsonNull
Prisma.AnyNull = objectEnumValues.instances.AnyNull
Prisma.NullTypes = {
DbNull: objectEnumValues.classes.DbNull,
JsonNull: objectEnumValues.classes.JsonNull,
AnyNull: objectEnumValues.classes.AnyNull
}
/**
* Enums
*/
// Based on
// https://github.com/microsoft/TypeScript/issues/3192#issuecomment-261720275
function makeEnum(x) { return x; }
exports.Prisma.AccountDataScalarFieldEnum = makeEnum({
id: 'id',
json: 'json',
updatedAt: 'updatedAt'
});
exports.Prisma.AlbumScalarFieldEnum = makeEnum({
id: 'id',
json: 'json',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
});
exports.Prisma.AppDataScalarFieldEnum = makeEnum({
id: 'id',
value: 'value'
});
exports.Prisma.AppleMusicAlbumScalarFieldEnum = makeEnum({
id: 'id',
json: 'json',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
});
exports.Prisma.AppleMusicArtistScalarFieldEnum = makeEnum({
id: 'id',
json: 'json',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
});
exports.Prisma.ArtistAlbumScalarFieldEnum = makeEnum({
id: 'id',
hotAlbums: 'hotAlbums',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
});
exports.Prisma.ArtistScalarFieldEnum = makeEnum({
id: 'id',
json: 'json',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
});
exports.Prisma.AudioScalarFieldEnum = makeEnum({
id: 'id',
bitRate: 'bitRate',
format: 'format',
source: 'source',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
queriedAt: 'queriedAt'
});
exports.Prisma.LyricsScalarFieldEnum = makeEnum({
id: 'id',
json: 'json',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
});
exports.Prisma.PlaylistScalarFieldEnum = makeEnum({
id: 'id',
json: 'json',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
});
exports.Prisma.SortOrder = makeEnum({
asc: 'asc',
desc: 'desc'
});
exports.Prisma.TrackScalarFieldEnum = makeEnum({
id: 'id',
json: 'json',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
});
exports.Prisma.TransactionIsolationLevel = makeStrictEnum({
Serializable: 'Serializable'
});
exports.Prisma.ModelName = makeEnum({
AccountData: 'AccountData',
AppData: 'AppData',
Track: 'Track',
Album: 'Album',
Artist: 'Artist',
ArtistAlbum: 'ArtistAlbum',
Playlist: 'Playlist',
Audio: 'Audio',
Lyrics: 'Lyrics',
AppleMusicAlbum: 'AppleMusicAlbum',
AppleMusicArtist: 'AppleMusicArtist'
});
/**
* Create the Client
*/
class PrismaClient {
constructor() {
throw new Error(
`PrismaClient is unable to be run in the browser.
In case this error is unexpected for you, please report it in https://github.com/prisma/prisma/issues`,
)
}
}
exports.PrismaClient = PrismaClient
Object.assign(exports, Prisma)

12729
packages/desktop/prisma/client/index.d.ts vendored Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,6 @@
{
"name": ".prisma/client",
"main": "index.js",
"types": "index.d.ts",
"browser": "index-browser.js"
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,323 @@
declare class AnyNull extends NullTypesEnumValue {
}
declare class DbNull extends NullTypesEnumValue {
}
export declare namespace Decimal {
export type Constructor = typeof Decimal;
export type Instance = Decimal;
export type Rounding = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
export type Modulo = Rounding | 9;
export type Value = string | number | Decimal;
// http://mikemcl.github.io/decimal.js/#constructor-properties
export interface Config {
precision?: number;
rounding?: Rounding;
toExpNeg?: number;
toExpPos?: number;
minE?: number;
maxE?: number;
crypto?: boolean;
modulo?: Modulo;
defaults?: boolean;
}
}
export declare class Decimal {
readonly d: number[];
readonly e: number;
readonly s: number;
private readonly toStringTag: string;
constructor(n: Decimal.Value);
absoluteValue(): Decimal;
abs(): Decimal;
ceil(): Decimal;
clampedTo(min: Decimal.Value, max: Decimal.Value): Decimal;
clamp(min: Decimal.Value, max: Decimal.Value): Decimal;
comparedTo(n: Decimal.Value): number;
cmp(n: Decimal.Value): number;
cosine(): Decimal;
cos(): Decimal;
cubeRoot(): Decimal;
cbrt(): Decimal;
decimalPlaces(): number;
dp(): number;
dividedBy(n: Decimal.Value): Decimal;
div(n: Decimal.Value): Decimal;
dividedToIntegerBy(n: Decimal.Value): Decimal;
divToInt(n: Decimal.Value): Decimal;
equals(n: Decimal.Value): boolean;
eq(n: Decimal.Value): boolean;
floor(): Decimal;
greaterThan(n: Decimal.Value): boolean;
gt(n: Decimal.Value): boolean;
greaterThanOrEqualTo(n: Decimal.Value): boolean;
gte(n: Decimal.Value): boolean;
hyperbolicCosine(): Decimal;
cosh(): Decimal;
hyperbolicSine(): Decimal;
sinh(): Decimal;
hyperbolicTangent(): Decimal;
tanh(): Decimal;
inverseCosine(): Decimal;
acos(): Decimal;
inverseHyperbolicCosine(): Decimal;
acosh(): Decimal;
inverseHyperbolicSine(): Decimal;
asinh(): Decimal;
inverseHyperbolicTangent(): Decimal;
atanh(): Decimal;
inverseSine(): Decimal;
asin(): Decimal;
inverseTangent(): Decimal;
atan(): Decimal;
isFinite(): boolean;
isInteger(): boolean;
isInt(): boolean;
isNaN(): boolean;
isNegative(): boolean;
isNeg(): boolean;
isPositive(): boolean;
isPos(): boolean;
isZero(): boolean;
lessThan(n: Decimal.Value): boolean;
lt(n: Decimal.Value): boolean;
lessThanOrEqualTo(n: Decimal.Value): boolean;
lte(n: Decimal.Value): boolean;
logarithm(n?: Decimal.Value): Decimal;
log(n?: Decimal.Value): Decimal;
minus(n: Decimal.Value): Decimal;
sub(n: Decimal.Value): Decimal;
modulo(n: Decimal.Value): Decimal;
mod(n: Decimal.Value): Decimal;
naturalExponential(): Decimal;
exp(): Decimal;
naturalLogarithm(): Decimal;
ln(): Decimal;
negated(): Decimal;
neg(): Decimal;
plus(n: Decimal.Value): Decimal;
add(n: Decimal.Value): Decimal;
precision(includeZeros?: boolean): number;
sd(includeZeros?: boolean): number;
round(): Decimal;
sine() : Decimal;
sin() : Decimal;
squareRoot(): Decimal;
sqrt(): Decimal;
tangent() : Decimal;
tan() : Decimal;
times(n: Decimal.Value): Decimal;
mul(n: Decimal.Value) : Decimal;
toBinary(significantDigits?: number): string;
toBinary(significantDigits: number, rounding: Decimal.Rounding): string;
toDecimalPlaces(decimalPlaces?: number): Decimal;
toDecimalPlaces(decimalPlaces: number, rounding: Decimal.Rounding): Decimal;
toDP(decimalPlaces?: number): Decimal;
toDP(decimalPlaces: number, rounding: Decimal.Rounding): Decimal;
toExponential(decimalPlaces?: number): string;
toExponential(decimalPlaces: number, rounding: Decimal.Rounding): string;
toFixed(decimalPlaces?: number): string;
toFixed(decimalPlaces: number, rounding: Decimal.Rounding): string;
toFraction(max_denominator?: Decimal.Value): Decimal[];
toHexadecimal(significantDigits?: number): string;
toHexadecimal(significantDigits: number, rounding: Decimal.Rounding): string;
toHex(significantDigits?: number): string;
toHex(significantDigits: number, rounding?: Decimal.Rounding): string;
toJSON(): string;
toNearest(n: Decimal.Value, rounding?: Decimal.Rounding): Decimal;
toNumber(): number;
toOctal(significantDigits?: number): string;
toOctal(significantDigits: number, rounding: Decimal.Rounding): string;
toPower(n: Decimal.Value): Decimal;
pow(n: Decimal.Value): Decimal;
toPrecision(significantDigits?: number): string;
toPrecision(significantDigits: number, rounding: Decimal.Rounding): string;
toSignificantDigits(significantDigits?: number): Decimal;
toSignificantDigits(significantDigits: number, rounding: Decimal.Rounding): Decimal;
toSD(significantDigits?: number): Decimal;
toSD(significantDigits: number, rounding: Decimal.Rounding): Decimal;
toString(): string;
truncated(): Decimal;
trunc(): Decimal;
valueOf(): string;
static abs(n: Decimal.Value): Decimal;
static acos(n: Decimal.Value): Decimal;
static acosh(n: Decimal.Value): Decimal;
static add(x: Decimal.Value, y: Decimal.Value): Decimal;
static asin(n: Decimal.Value): Decimal;
static asinh(n: Decimal.Value): Decimal;
static atan(n: Decimal.Value): Decimal;
static atanh(n: Decimal.Value): Decimal;
static atan2(y: Decimal.Value, x: Decimal.Value): Decimal;
static cbrt(n: Decimal.Value): Decimal;
static ceil(n: Decimal.Value): Decimal;
static clamp(n: Decimal.Value, min: Decimal.Value, max: Decimal.Value): Decimal;
static clone(object?: Decimal.Config): Decimal.Constructor;
static config(object: Decimal.Config): Decimal.Constructor;
static cos(n: Decimal.Value): Decimal;
static cosh(n: Decimal.Value): Decimal;
static div(x: Decimal.Value, y: Decimal.Value): Decimal;
static exp(n: Decimal.Value): Decimal;
static floor(n: Decimal.Value): Decimal;
static hypot(...n: Decimal.Value[]): Decimal;
static isDecimal(object: any): object is Decimal;
static ln(n: Decimal.Value): Decimal;
static log(n: Decimal.Value, base?: Decimal.Value): Decimal;
static log2(n: Decimal.Value): Decimal;
static log10(n: Decimal.Value): Decimal;
static max(...n: Decimal.Value[]): Decimal;
static min(...n: Decimal.Value[]): Decimal;
static mod(x: Decimal.Value, y: Decimal.Value): Decimal;
static mul(x: Decimal.Value, y: Decimal.Value): Decimal;
static noConflict(): Decimal.Constructor; // Browser only
static pow(base: Decimal.Value, exponent: Decimal.Value): Decimal;
static random(significantDigits?: number): Decimal;
static round(n: Decimal.Value): Decimal;
static set(object: Decimal.Config): Decimal.Constructor;
static sign(n: Decimal.Value): number;
static sin(n: Decimal.Value): Decimal;
static sinh(n: Decimal.Value): Decimal;
static sqrt(n: Decimal.Value): Decimal;
static sub(x: Decimal.Value, y: Decimal.Value): Decimal;
static sum(...n: Decimal.Value[]): Decimal;
static tan(n: Decimal.Value): Decimal;
static tanh(n: Decimal.Value): Decimal;
static trunc(n: Decimal.Value): Decimal;
static readonly default?: Decimal.Constructor;
static readonly Decimal?: Decimal.Constructor;
static readonly precision: number;
static readonly rounding: Decimal.Rounding;
static readonly toExpNeg: number;
static readonly toExpPos: number;
static readonly minE: number;
static readonly maxE: number;
static readonly crypto: boolean;
static readonly modulo: Decimal.Modulo;
static readonly ROUND_UP: 0;
static readonly ROUND_DOWN: 1;
static readonly ROUND_CEIL: 2;
static readonly ROUND_FLOOR: 3;
static readonly ROUND_HALF_UP: 4;
static readonly ROUND_HALF_DOWN: 5;
static readonly ROUND_HALF_EVEN: 6;
static readonly ROUND_HALF_CEIL: 7;
static readonly ROUND_HALF_FLOOR: 8;
static readonly EUCLID: 9;
}
declare class JsonNull extends NullTypesEnumValue {
}
/**
* Generates more strict variant of an enum which, unlike regular enum,
* throws on non-existing property access. This can be useful in following situations:
* - we have an API, that accepts both `undefined` and `SomeEnumType` as an input
* - enum values are generated dynamically from DMMF.
*
* In that case, if using normal enums and no compile-time typechecking, using non-existing property
* will result in `undefined` value being used, which will be accepted. Using strict enum
* in this case will help to have a runtime exception, telling you that you are probably doing something wrong.
*
* Note: if you need to check for existence of a value in the enum you can still use either
* `in` operator or `hasOwnProperty` function.
*
* @param definition
* @returns
*/
export declare function makeStrictEnum<T extends Record<PropertyKey, string | number>>(definition: T): T;
declare class NullTypesEnumValue extends ObjectEnumValue {
_getNamespace(): string;
}
/**
* Base class for unique values of object-valued enums.
*/
declare abstract class ObjectEnumValue {
constructor(arg?: symbol);
abstract _getNamespace(): string;
_getName(): string;
toString(): string;
}
export declare const objectEnumValues: {
classes: {
DbNull: typeof DbNull;
JsonNull: typeof JsonNull;
AnyNull: typeof AnyNull;
};
instances: {
DbNull: DbNull;
JsonNull: JsonNull;
AnyNull: AnyNull;
};
};
export { }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,87 @@
generator client {
provider = "prisma-client-js"
output = "./client"
binaryTargets = ["native", "darwin", "darwin-arm64"]
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model AccountData {
id String @id @unique
json String
updatedAt DateTime @updatedAt
}
model AppData {
id String @id @unique
value String
}
model Track {
id Int @id @unique
json String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Album {
id Int @id @unique
json String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Artist {
id Int @id @unique
json String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ArtistAlbum {
id Int @id @unique
hotAlbums String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Playlist {
id Int @id @unique
json String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Audio {
id Int @id @unique
bitRate Int
format String
source String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
queriedAt DateTime @default(now())
}
model Lyrics {
id Int @id @unique
json String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model AppleMusicAlbum {
id Int @id @unique
json String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model AppleMusicArtist {
id Int @id @unique
json String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

Binary file not shown.

View file

@ -0,0 +1,87 @@
generator client {
provider = "prisma-client-js"
output = "./client"
binaryTargets = ["native", "darwin", "darwin-arm64"]
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model AccountData {
id String @id @unique
json String
updatedAt DateTime @updatedAt
}
model AppData {
id String @id @unique
value String
}
model Track {
id Int @id @unique
json String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Album {
id Int @id @unique
json String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Artist {
id Int @id @unique
json String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ArtistAlbum {
id Int @id @unique
hotAlbums String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Playlist {
id Int @id @unique
json String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Audio {
id Int @id @unique
bitRate Int
format String
source String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
queriedAt DateTime @default(now())
}
model Lyrics {
id Int @id @unique
json String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model AppleMusicAlbum {
id Int @id @unique
json String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model AppleMusicArtist {
id Int @id @unique
json String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View file

@ -1,10 +0,0 @@
import { expect, test, describe, vi } from 'vitest'
import { pairDevice, scanDevices } from '../main/airplay'
// test('scanDevices', async () => {
// console.log(await scanDevices())
// }, 10000000)
// test('pairDevice', async () => {
// console.log(await pairDevice('58:D3:49:F0:C9:71', 'airplay'))
// }, 100000)

View file

@ -12,18 +12,11 @@ import {
FetchUserLikedTracksIDsResponse,
FetchUserPlaylistsResponse,
} from './api/User'
import {
FetchAudioSourceResponse,
FetchLyricResponse,
FetchTracksResponse,
} from './api/Track'
import {
FetchPlaylistResponse,
FetchRecommendedPlaylistsResponse,
} from './api/Playlists'
import { FetchAudioSourceResponse, FetchLyricResponse, FetchTracksResponse } from './api/Track'
import { FetchPlaylistResponse, FetchRecommendedPlaylistsResponse } from './api/Playlists'
import { AppleMusicAlbum, AppleMusicArtist } from 'AppleMusic'
export const enum APIs {
export enum CacheAPIs {
Album = 'album',
Artist = 'artists',
ArtistAlbum = 'artist/album',
@ -32,7 +25,7 @@ export const enum APIs {
Personalized = 'personalized',
Playlist = 'playlist/detail',
RecommendResource = 'recommend/resource',
SongUrl = 'song/url',
SongUrl = 'song/url/v1',
Track = 'song/detail',
UserAccount = 'user/account',
UserAlbums = 'album/sublist',
@ -42,56 +35,53 @@ export const enum APIs {
ListenedRecords = 'user/record',
// not netease api
Artists = 'artistsNotNetease',
CoverColor = 'cover_color',
AppleMusicAlbum = 'apple_music_album',
AppleMusicArtist = 'apple_music_artist',
}
export interface APIsParams {
[APIs.Album]: { id: number }
[APIs.Artist]: { id: number }
[APIs.ArtistAlbum]: { id: number }
[APIs.Likelist]: void
[APIs.Lyric]: { id: number }
[APIs.Personalized]: void
[APIs.Playlist]: { id: number }
[APIs.RecommendResource]: void
[APIs.SongUrl]: { id: string }
[APIs.Track]: { ids: string }
[APIs.UserAccount]: void
[APIs.UserAlbums]: void
[APIs.UserArtists]: void
[APIs.UserPlaylist]: void
[APIs.SimilarArtist]: { id: number }
[APIs.ListenedRecords]: { id: number; type: number }
export interface CacheAPIsParams {
[CacheAPIs.Album]: { id: number }
[CacheAPIs.Artist]: { id: number }
[CacheAPIs.ArtistAlbum]: { id: number }
[CacheAPIs.Likelist]: void
[CacheAPIs.Lyric]: { id: number }
[CacheAPIs.Personalized]: void
[CacheAPIs.Playlist]: { id: number }
[CacheAPIs.RecommendResource]: void
[CacheAPIs.SongUrl]: { id: string }
[CacheAPIs.Track]: { ids: string }
[CacheAPIs.UserAccount]: void
[CacheAPIs.UserAlbums]: void
[CacheAPIs.UserArtists]: void
[CacheAPIs.UserPlaylist]: void
[CacheAPIs.SimilarArtist]: { id: number }
[CacheAPIs.ListenedRecords]: { id: number; type: number }
[APIs.Artists]: { ids: number[] }
[APIs.CoverColor]: { id: number }
[APIs.AppleMusicAlbum]: { id: number }
[APIs.AppleMusicArtist]: { id: number }
[CacheAPIs.CoverColor]: { id: number }
[CacheAPIs.AppleMusicAlbum]: { id: number }
[CacheAPIs.AppleMusicArtist]: { id: number }
}
export interface APIsResponse {
[APIs.Album]: FetchAlbumResponse
[APIs.Artist]: FetchArtistResponse
[APIs.ArtistAlbum]: FetchArtistAlbumsResponse
[APIs.Likelist]: FetchUserLikedTracksIDsResponse
[APIs.Lyric]: FetchLyricResponse
[APIs.Personalized]: FetchRecommendedPlaylistsResponse
[APIs.Playlist]: FetchPlaylistResponse
[APIs.RecommendResource]: FetchRecommendedPlaylistsResponse
[APIs.SongUrl]: FetchAudioSourceResponse
[APIs.Track]: FetchTracksResponse
[APIs.UserAccount]: FetchUserAccountResponse
[APIs.UserAlbums]: FetchUserAlbumsResponse
[APIs.UserArtists]: FetchUserArtistsResponse
[APIs.UserPlaylist]: FetchUserPlaylistsResponse
[APIs.SimilarArtist]: FetchSimilarArtistsResponse
[APIs.ListenedRecords]: FetchListenedRecordsResponse
export interface CacheAPIsResponse {
[CacheAPIs.Album]: FetchAlbumResponse
[CacheAPIs.Artist]: FetchArtistResponse
[CacheAPIs.ArtistAlbum]: FetchArtistAlbumsResponse
[CacheAPIs.Likelist]: FetchUserLikedTracksIDsResponse
[CacheAPIs.Lyric]: FetchLyricResponse
[CacheAPIs.Personalized]: FetchRecommendedPlaylistsResponse
[CacheAPIs.Playlist]: FetchPlaylistResponse
[CacheAPIs.RecommendResource]: FetchRecommendedPlaylistsResponse
[CacheAPIs.SongUrl]: FetchAudioSourceResponse
[CacheAPIs.Track]: FetchTracksResponse
[CacheAPIs.UserAccount]: FetchUserAccountResponse
[CacheAPIs.UserAlbums]: FetchUserAlbumsResponse
[CacheAPIs.UserArtists]: FetchUserArtistsResponse
[CacheAPIs.UserPlaylist]: FetchUserPlaylistsResponse
[CacheAPIs.SimilarArtist]: FetchSimilarArtistsResponse
[CacheAPIs.ListenedRecords]: FetchListenedRecordsResponse
[APIs.Artists]: FetchArtistResponse[]
[APIs.CoverColor]: string | undefined
[APIs.AppleMusicAlbum]: AppleMusicAlbum | 'no'
[APIs.AppleMusicArtist]: AppleMusicArtist | 'no'
[CacheAPIs.CoverColor]: string | undefined
[CacheAPIs.AppleMusicAlbum]: AppleMusicAlbum | 'no'
[CacheAPIs.AppleMusicArtist]: AppleMusicArtist | 'no'
}

View file

@ -1,5 +1,5 @@
import { AppleMusicAlbum, AppleMusicArtist } from './AppleMusic'
import { APIs } from './CacheAPIs'
import { CacheAPIs } from './CacheAPIs'
import { RepeatMode } from './playerDataTypes'
export const enum IpcChannels {
@ -38,7 +38,7 @@ export interface IpcChannelsParams {
[IpcChannels.IsMaximized]: void
[IpcChannels.FullscreenStateChange]: void
[IpcChannels.GetApiCache]: {
api: APIs
api: CacheAPIs
query?: any
}
[IpcChannels.DevDbExportJson]: void

View file

@ -1,5 +1,6 @@
interface FetchAppleMusicAlbumParams {
neteaseId: number | string
lang?: 'zh-CN' | 'en-US'
}
interface FetchAppleMusicAlbumResponse {
@ -17,6 +18,7 @@ interface FetchAppleMusicAlbumResponse {
interface FetchAppleMusicArtistParams {
neteaseId: number | string
lang?: 'zh-CN' | 'en-US'
}
interface FetchAppleMusicArtistResponse {

View file

@ -1,12 +1,8 @@
import { fetchAlbum } from '@/web/api/album'
import reactQueryClient from '@/web/utils/reactQueryClient'
import { IpcChannels } from '@/shared/IpcChannels'
import { APIs } from '@/shared/CacheAPIs'
import {
FetchAlbumParams,
AlbumApiNames,
FetchAlbumResponse,
} from '@/shared/api/Album'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { FetchAlbumParams, AlbumApiNames, FetchAlbumResponse } from '@/shared/api/Album'
import { useQuery } from '@tanstack/react-query'
const fetch = async (params: FetchAlbumParams) => {
@ -17,11 +13,9 @@ const fetch = async (params: FetchAlbumParams) => {
return album
}
const fetchFromCache = async (
params: FetchAlbumParams
): Promise<FetchAlbumResponse | undefined> =>
const fetchFromCache = async (params: FetchAlbumParams): Promise<FetchAlbumResponse | undefined> =>
window.ipcRenderer?.invoke(IpcChannels.GetApiCache, {
api: APIs.Album,
api: CacheAPIs.Album,
query: params,
})
@ -48,22 +42,14 @@ export default function useAlbum(params: FetchAlbumParams) {
}
export function fetchAlbumWithReactQuery(params: FetchAlbumParams) {
return reactQueryClient.fetchQuery(
[AlbumApiNames.FetchAlbum, params],
() => fetch(params),
{
staleTime: Infinity,
}
)
return reactQueryClient.fetchQuery([AlbumApiNames.FetchAlbum, params], () => fetch(params), {
staleTime: Infinity,
})
}
export async function prefetchAlbum(params: FetchAlbumParams) {
if (await fetchFromCache(params)) return
await reactQueryClient.prefetchQuery(
[AlbumApiNames.FetchAlbum, params],
() => fetch(params),
{
staleTime: Infinity,
}
)
await reactQueryClient.prefetchQuery([AlbumApiNames.FetchAlbum, params], () => fetch(params), {
staleTime: Infinity,
})
}

View file

@ -1,11 +1,7 @@
import { fetchArtist } from '@/web/api/artist'
import { IpcChannels } from '@/shared/IpcChannels'
import { APIs } from '@/shared/CacheAPIs'
import {
FetchArtistParams,
ArtistApiNames,
FetchArtistResponse,
} from '@/shared/api/Artist'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { FetchArtistParams, ArtistApiNames, FetchArtistResponse } from '@/shared/api/Artist'
import { useQuery } from '@tanstack/react-query'
import reactQueryClient from '@/web/utils/reactQueryClient'
@ -13,7 +9,7 @@ const fetchFromCache = async (
params: FetchArtistParams
): Promise<FetchArtistResponse | undefined> =>
window.ipcRenderer?.invoke(IpcChannels.GetApiCache, {
api: APIs.Artist,
api: CacheAPIs.Artist,
query: params,
})

View file

@ -1,6 +1,6 @@
import { fetchArtistAlbums } from '@/web/api/artist'
import { IpcChannels } from '@/shared/IpcChannels'
import { APIs } from '@/shared/CacheAPIs'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { FetchArtistAlbumsParams, ArtistApiNames } from '@/shared/api/Artist'
import { useQuery } from '@tanstack/react-query'
import reactQueryClient from '@/web/utils/reactQueryClient'
@ -13,7 +13,7 @@ export default function useArtistAlbums(params: FetchArtistAlbumsParams) {
// fetch from cache as placeholder
window.ipcRenderer
?.invoke(IpcChannels.GetApiCache, {
api: APIs.ArtistAlbum,
api: CacheAPIs.ArtistAlbum,
query: {
id: params.id,
},

View file

@ -1,11 +1,7 @@
import { fetchArtistMV } from '@/web/api/artist'
import { IpcChannels } from '@/shared/IpcChannels'
import { APIs } from '@/shared/CacheAPIs'
import {
FetchArtistMVParams,
ArtistApiNames,
FetchArtistMVResponse,
} from '@/shared/api/Artist'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { FetchArtistMVParams, ArtistApiNames, FetchArtistMVResponse } from '@/shared/api/Artist'
import { useQuery } from '@tanstack/react-query'
export default function useArtistMV(params: FetchArtistMVParams) {

View file

@ -1,6 +1,6 @@
import { fetchArtist } from '@/web/api/artist'
import { IpcChannels } from '@/shared/IpcChannels'
import { APIs } from '@/shared/CacheAPIs'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { ArtistApiNames } from '@/shared/api/Artist'
import { useQuery } from '@tanstack/react-query'
import reactQueryClient from '@/web/utils/reactQueryClient'
@ -11,21 +11,15 @@ export default function useArtists(ids: number[]) {
() =>
Promise.all(
ids.map(async id => {
const queryData = reactQueryClient.getQueryData([
ArtistApiNames.FetchArtist,
{ id },
])
const queryData = reactQueryClient.getQueryData([ArtistApiNames.FetchArtist, { id }])
if (queryData) return queryData
const cache = await window.ipcRenderer?.invoke(
IpcChannels.GetApiCache,
{
api: APIs.Artist,
query: {
id,
},
}
)
const cache = await window.ipcRenderer?.invoke(IpcChannels.GetApiCache, {
api: CacheAPIs.Artist,
query: {
id,
},
})
if (cache) return cache
return fetchArtist({ id })

View file

@ -1,7 +1,7 @@
import { fetchLyric } from '@/web/api/track'
import reactQueryClient from '@/web/utils/reactQueryClient'
import { FetchLyricParams, TrackApiNames } from '@/shared/api/Track'
import { APIs } from '@/shared/CacheAPIs'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { IpcChannels } from '@/shared/IpcChannels'
import { useQuery } from '@tanstack/react-query'
@ -12,7 +12,7 @@ export default function useLyric(params: FetchLyricParams) {
async () => {
// fetch from cache as initial data
const cache = window.ipcRenderer?.invoke(IpcChannels.GetApiCache, {
api: APIs.Lyric,
api: CacheAPIs.Lyric,
query: {
id: params.id,
},

View file

@ -1,12 +1,6 @@
import { fetchMV, fetchMVUrl } from '@/web/api/mv'
import { IpcChannels } from '@/shared/IpcChannels'
import { APIs } from '@/shared/CacheAPIs'
import {
MVApiNames,
FetchMVParams,
FetchMVResponse,
FetchMVUrlParams,
} from '@/shared/api/MV'
import { MVApiNames, FetchMVParams, FetchMVResponse, FetchMVUrlParams } from '@/shared/api/MV'
import { useQuery } from '@tanstack/react-query'
export default function useMV(params: FetchMVParams) {

View file

@ -1,7 +1,7 @@
import { fetchPlaylist } from '@/web/api/playlist'
import reactQueryClient from '@/web/utils/reactQueryClient'
import { IpcChannels } from '@/shared/IpcChannels'
import { APIs } from '@/shared/CacheAPIs'
import { CacheAPIs } from '@/shared/CacheAPIs'
import {
FetchPlaylistParams,
PlaylistApiNames,
@ -17,7 +17,7 @@ export const fetchFromCache = async (
params: FetchPlaylistParams
): Promise<FetchPlaylistResponse | undefined> =>
window.ipcRenderer?.invoke(IpcChannels.GetApiCache, {
api: APIs.Playlist,
api: CacheAPIs.Playlist,
query: params,
})

View file

@ -1,6 +1,6 @@
import { fetchSimilarArtists } from '@/web/api/artist'
import { IpcChannels } from '@/shared/IpcChannels'
import { APIs } from '@/shared/CacheAPIs'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { FetchSimilarArtistsParams, ArtistApiNames } from '@/shared/api/Artist'
import { useQuery } from '@tanstack/react-query'
import reactQueryClient from '@/web/utils/reactQueryClient'
@ -12,7 +12,7 @@ export default function useSimilarArtists(params: FetchSimilarArtistsParams) {
() => {
window.ipcRenderer
?.invoke(IpcChannels.GetApiCache, {
api: APIs.SimilarArtist,
api: CacheAPIs.SimilarArtist,
query: {
id: params.id,
},

View file

@ -8,7 +8,7 @@ import {
FetchTracksResponse,
TrackApiNames,
} from '@/shared/api/Track'
import { APIs } from '@/shared/CacheAPIs'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { useQuery } from '@tanstack/react-query'
export default function useTracks(params: FetchTracksParams) {
@ -17,7 +17,7 @@ export default function useTracks(params: FetchTracksParams) {
async () => {
// fetch from cache as initial data
const cache = await window.ipcRenderer?.invoke(IpcChannels.GetApiCache, {
api: APIs.Track,
api: CacheAPIs.Track,
query: {
ids: params.ids.join(','),
},
@ -40,7 +40,7 @@ export function fetchTracksWithReactQuery(params: FetchTracksParams) {
[TrackApiNames.FetchTracks, params],
async () => {
const cache = await window.ipcRenderer?.invoke(IpcChannels.GetApiCache, {
api: APIs.Track,
api: CacheAPIs.Track,
query: {
ids: params.ids.join(','),
},

View file

@ -1,6 +1,6 @@
import { fetchUserAccount } from '@/web/api/user'
import { UserApiNames, FetchUserAccountResponse } from '@/shared/api/User'
import { APIs } from '@/shared/CacheAPIs'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { IpcChannels } from '@/shared/IpcChannels'
import { useMutation, useQuery } from '@tanstack/react-query'
import { logout } from '../auth'
@ -16,7 +16,7 @@ export default function useUser() {
if (!existsQueryData) {
window.ipcRenderer
?.invoke(IpcChannels.GetApiCache, {
api: APIs.UserAccount,
api: CacheAPIs.UserAccount,
})
.then(cache => {
if (cache) reactQueryClient.setQueryData(key, cache)

View file

@ -2,12 +2,8 @@ import { likeAAlbum } from '@/web/api/album'
import { useMutation, useQuery } from '@tanstack/react-query'
import useUser from './useUser'
import { IpcChannels } from '@/shared/IpcChannels'
import { APIs } from '@/shared/CacheAPIs'
import {
FetchUserAlbumsParams,
UserApiNames,
FetchUserAlbumsResponse,
} from '@/shared/api/User'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { FetchUserAlbumsParams, UserApiNames, FetchUserAlbumsResponse } from '@/shared/api/User'
import { fetchUserAlbums } from '../user'
import toast from 'react-hot-toast'
import reactQueryClient from '@/web/utils/reactQueryClient'
@ -25,7 +21,7 @@ export default function useUserAlbums(params: FetchUserAlbumsParams = {}) {
if (!existsQueryData) {
window.ipcRenderer
?.invoke(IpcChannels.GetApiCache, {
api: APIs.UserAlbums,
api: CacheAPIs.UserAlbums,
query: params,
})
.then(cache => {
@ -72,9 +68,7 @@ export const useMutationLikeAAlbum = () => {
}
// Snapshot the previous value
const previousData = reactQueryClient.getQueryData(
key
) as FetchUserAlbumsResponse
const previousData = reactQueryClient.getQueryData(key) as FetchUserAlbumsResponse
const isLiked = !!previousData?.data.find(a => a.id === albumID)
const newAlbums = cloneDeep(previousData!)
@ -88,21 +82,17 @@ export const useMutationLikeAAlbum = () => {
console.log({ albumID })
const albumFromCache: FetchAlbumResponse | undefined =
reactQueryClient.getQueryData([
AlbumApiNames.FetchAlbum,
{ id: albumID },
])
const albumFromCache: FetchAlbumResponse | undefined = reactQueryClient.getQueryData([
AlbumApiNames.FetchAlbum,
{ id: albumID },
])
console.log({ albumFromCache })
// 从api获取专辑
const album: FetchAlbumResponse | undefined = albumFromCache
? albumFromCache
: await reactQueryClient.fetchQuery([
AlbumApiNames.FetchAlbum,
{ id: albumID },
])
: await reactQueryClient.fetchQuery([AlbumApiNames.FetchAlbum, { id: albumID }])
if (!album?.album) {
toast.error('Failed to like album: unable to fetch album info')

View file

@ -1,6 +1,6 @@
import { fetchUserArtists } from '@/web/api/user'
import { UserApiNames, FetchUserArtistsResponse } from '@/shared/api/User'
import { APIs } from '@/shared/CacheAPIs'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { IpcChannels } from '@/shared/IpcChannels'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
@ -18,7 +18,7 @@ export default function useUserArtists() {
if (!existsQueryData) {
window.ipcRenderer
?.invoke(IpcChannels.GetApiCache, {
api: APIs.UserArtists,
api: CacheAPIs.UserArtists,
})
.then(cache => {
if (cache) reactQueryClient.setQueryData(key, cache)
@ -59,32 +59,24 @@ export const useMutationLikeAArtist = () => {
}
// Snapshot the previous value
const previousData = reactQueryClient.getQueryData(
key
) as FetchUserArtistsResponse
const previousData = reactQueryClient.getQueryData(key) as FetchUserArtistsResponse
const isLiked = !!previousData?.data.find(a => a.id === artistID)
const newLikedArtists = cloneDeep(previousData!)
if (isLiked) {
newLikedArtists.data = previousData.data.filter(
a => a.id !== artistID
)
newLikedArtists.data = previousData.data.filter(a => a.id !== artistID)
} else {
// 从react-query缓存获取歌手信息
const artistFromCache: FetchArtistResponse | undefined =
reactQueryClient.getQueryData([
ArtistApiNames.FetchArtist,
{ id: artistID },
])
const artistFromCache: FetchArtistResponse | undefined = reactQueryClient.getQueryData([
ArtistApiNames.FetchArtist,
{ id: artistID },
])
// 从api获取歌手信息
const artist: FetchArtistResponse | undefined = artistFromCache
? artistFromCache
: await reactQueryClient.fetchQuery([
ArtistApiNames.FetchArtist,
{ id: artistID },
])
: await reactQueryClient.fetchQuery([ArtistApiNames.FetchArtist, { id: artistID }])
if (!artist?.artist) {
toast.error('Failed to like artist: unable to fetch artist info')

View file

@ -2,12 +2,9 @@ import { likeATrack } from '@/web/api/track'
import useUser from './useUser'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { IpcChannels } from '@/shared/IpcChannels'
import { APIs } from '@/shared/CacheAPIs'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { fetchUserLikedTracksIDs } from '../user'
import {
FetchUserLikedTracksIDsResponse,
UserApiNames,
} from '@/shared/api/User'
import { FetchUserLikedTracksIDsResponse, UserApiNames } from '@/shared/api/User'
import { useQuery } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import reactQueryClient from '@/web/utils/reactQueryClient'
@ -24,7 +21,7 @@ export default function useUserLikedTracksIDs() {
if (!existsQueryData) {
window.ipcRenderer
?.invoke(IpcChannels.GetApiCache, {
api: APIs.Likelist,
api: CacheAPIs.Likelist,
query: {
uid,
},

View file

@ -1,6 +1,6 @@
import { fetchListenedRecords } from '@/web/api/user'
import { UserApiNames, FetchListenedRecordsResponse } from '@/shared/api/User'
import { APIs } from '@/shared/CacheAPIs'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { IpcChannels } from '@/shared/IpcChannels'
import { useQuery } from '@tanstack/react-query'
import useUser from './useUser'
@ -18,7 +18,7 @@ export default function useUserListenedRecords(params: { type: 'week' | 'all' })
if (!existsQueryData) {
window.ipcRenderer
?.invoke(IpcChannels.GetApiCache, {
api: APIs.ListenedRecords,
api: CacheAPIs.ListenedRecords,
})
.then(cache => {
if (cache) reactQueryClient.setQueryData(key, cache)

View file

@ -2,7 +2,7 @@ import { likeAPlaylist } from '@/web/api/playlist'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import useUser from './useUser'
import { IpcChannels } from '@/shared/IpcChannels'
import { APIs } from '@/shared/CacheAPIs'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { fetchUserPlaylists } from '@/web/api/user'
import { FetchUserPlaylistsResponse, UserApiNames } from '@/shared/api/User'
import toast from 'react-hot-toast'
@ -33,7 +33,7 @@ export default function useUserPlaylists() {
if (!existsQueryData) {
window.ipcRenderer
?.invoke(IpcChannels.GetApiCache, {
api: APIs.UserPlaylist,
api: CacheAPIs.UserPlaylist,
query: {
uid: params.uid,
},
@ -46,11 +46,7 @@ export default function useUserPlaylists() {
return fetchUserPlaylists(params)
},
{
enabled: !!(
!!params.uid &&
params.uid !== 0 &&
params.offset !== undefined
),
enabled: !!(!!params.uid && params.uid !== 0 && params.offset !== undefined),
refetchOnWindowFocus: true,
}
)
@ -69,10 +65,7 @@ export const useMutationLikeAPlaylist = () => {
}
const response = await likeAPlaylist({
id: playlistID,
t:
userPlaylists.playlist.findIndex(p => p.id === playlistID) > -1
? 2
: 1,
t: userPlaylists.playlist.findIndex(p => p.id === playlistID) > -1 ? 2 : 1,
})
if (response.code !== 200) throw new Error((response as any).msg)
return response
@ -90,9 +83,7 @@ export const useMutationLikeAPlaylist = () => {
}
// Snapshot the previous value
const previousData = reactQueryClient.getQueryData(
key
) as FetchUserPlaylistsResponse
const previousData = reactQueryClient.getQueryData(key) as FetchUserPlaylistsResponse
const isLiked = !!previousData?.playlist.find(p => p.id === playlistID)
const newPlaylists = cloneDeep(previousData!)
@ -100,17 +91,12 @@ export const useMutationLikeAPlaylist = () => {
console.log({ isLiked })
if (isLiked) {
newPlaylists.playlist = previousData.playlist.filter(
p => p.id !== playlistID
)
newPlaylists.playlist = previousData.playlist.filter(p => p.id !== playlistID)
} else {
// 从react-query缓存获取歌单信息
const playlistFromCache: FetchPlaylistResponse | undefined =
reactQueryClient.getQueryData([
PlaylistApiNames.FetchPlaylist,
{ id: playlistID },
])
reactQueryClient.getQueryData([PlaylistApiNames.FetchPlaylist, { id: playlistID }])
// 从api获取歌单信息
const playlist: FetchPlaylistResponse | undefined = playlistFromCache
@ -121,9 +107,7 @@ export const useMutationLikeAPlaylist = () => {
])
if (!playlist?.playlist) {
toast.error(
'Failed to like playlist: unable to fetch playlist info'
)
toast.error('Failed to like playlist: unable to fetch playlist info')
throw new Error('unable to fetch playlist info')
}
newPlaylists.playlist.splice(1, 0, playlist.playlist)

View file

@ -3,7 +3,7 @@ import {
FetchListenedRecordsResponse,
FetchUserVideosResponse,
} from '@/shared/api/User'
import { APIs } from '@/shared/CacheAPIs'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { IpcChannels } from '@/shared/IpcChannels'
import { useMutation, useQuery } from '@tanstack/react-query'
import useUser from './useUser'
@ -25,7 +25,7 @@ export default function useUserVideos() {
// if (!existsQueryData) {
// window.ipcRenderer
// ?.invoke(IpcChannels.GetApiCache, {
// api: APIs.Likelist,
// api: CacheAPIs.Likelist,
// query: {
// uid,
// },

View file

@ -1,67 +0,0 @@
import player from '@/web/states/player'
import { css, cx } from '@emotion/css'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useSnapshot } from 'valtio'
const useAirplayDevices = () => {
return useQuery(['useAirplayDevices'], () =>
window.ipcRenderer?.invoke('airplay-scan-devices')
)
}
const Airplay = () => {
const [showPanel, setShowPanel] = useState(false)
const { data: devices, isLoading } = useAirplayDevices()
const { remoteDevice } = useSnapshot(player)
const selectedAirplayDeviceID =
remoteDevice?.protocol === 'airplay' ? remoteDevice?.id : ''
return (
<div
className={cx(
'fixed z-20',
css`
top: 46px;
right: 256px;
`
)}
>
<div
onClick={() => setShowPanel(!showPanel)}
className='flex h-12 w-12 items-center justify-center rounded-full bg-white/20 text-24 text-white'
>
A
</div>
{showPanel && (
<div
className={cx(
'absolute rounded-24 border border-white/10 bg-black/60 p-2 backdrop-blur-xl',
css`
width: 256px;
height: 256px;
`
)}
>
{devices?.devices?.map(device => (
<div
key={device.identifier}
className={cx(
'rounded-12 p-2 hover:bg-white/10',
device.identifier === selectedAirplayDeviceID
? 'text-brand-500'
: 'text-white'
)}
onClick={() => player.switchToAirplayDevice(device.identifier)}
>
{device.name}
</div>
))}
</div>
)}
</div>
)
}
export default Airplay

View file

@ -87,7 +87,10 @@ const AlbumContextMenu = () => {
type: 'item',
label: t`context-menu.copy-r3play-link`,
onClick: () => {
copyToClipboard(`${window.location.origin}/album/${dataSourceID}`)
const baseUrl = window.env?.isElectron
? 'https://r3play.app'
: window.location.origin
copyToClipboard(`${baseUrl}/album/${dataSourceID}`)
toast.success(t`toasts.copied`)
},
},

View file

@ -0,0 +1,100 @@
import { css, cx } from '@emotion/css'
import { AnimatePresence, motion } from 'framer-motion'
import { useEffect } from 'react'
import { createPortal } from 'react-dom'
import uiStates from '../states/uiStates'
import { ease } from '../utils/const'
import Icon from './Icon'
function DescriptionViewer({
description,
title,
isOpen,
onClose,
}: {
description: string
title: string
isOpen: boolean
onClose: () => void
}) {
useEffect(() => {
uiStates.isPauseVideos = isOpen
}, [isOpen])
return createPortal(
<>
{/* Blur bg */}
<AnimatePresence>
{isOpen && (
<motion.div
className='fixed inset-0 z-30 bg-black/70 backdrop-blur-3xl lg:rounded-24'
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { duration: 0.3 } }}
exit={{ opacity: 0, transition: { duration: 0.3, delay: 0.3 } }}
transition={{ ease }}
></motion.div>
)}
</AnimatePresence>
{/* Content */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { duration: 0.3, delay: 0.3 } }}
exit={{ opacity: 0, transition: { duration: 0.3 } }}
transition={{ ease }}
className={cx('fixed inset-0 z-30 flex flex-col items-center justify-center')}
>
<div className='relative'>
{/* Title */}
<div className='line-clamp-1 absolute -top-8 mx-44 max-w-2xl select-none text-32 font-extrabold text-neutral-100'>
{title}
</div>
{/* Description */}
<div
className={css`
mask-image: linear-gradient(to top, transparent 0px, black 32px); // 底部渐变遮罩
`}
>
<div
className={cx(
'no-scrollbar relative mx-44 max-w-2xl overflow-scroll',
css`
max-height: 60vh;
mask-image: linear-gradient(
to bottom,
transparent 12px,
black 32px
); // 顶部渐变遮罩
`
)}
>
<p
dangerouslySetInnerHTML={{ __html: description + description }}
className='mt-8 whitespace-pre-wrap pb-8 text-16 font-bold leading-6 text-neutral-200'
></p>
</div>
</div>
{/* Close button */}
<div className='absolute -bottom-24 flex w-full justify-center'>
<div
onClick={onClose}
className='flex h-14 w-14 items-center justify-center rounded-full bg-white/10 text-white/50 transition-colors duration-300 hover:bg-white/20 hover:text-white/70'
>
<Icon name='x' className='h-6 w-6' />
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</>,
document.body
)
}
export default DescriptionViewer

View file

@ -6,48 +6,41 @@ import { useAnimation, motion } from 'framer-motion'
import { useState, useRef, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useSnapshot } from 'valtio'
import { subscribeKey } from 'valtio/utils'
const Cover = () => {
const { track } = useSnapshot(player)
const [cover, setCover] = useState(track?.al.picUrl)
const animationStartTime = useRef(0)
const controls = useAnimation()
const duration = 150 // ms
const navigate = useNavigate()
useEffect(() => {
const resizedCover = resizeImage(track?.al.picUrl || '', 'lg')
const animate = async () => {
animationStartTime.current = Date.now()
await controls.start({ opacity: 0 })
setCover(resizedCover)
}
animate()
}, [controls, track?.al.picUrl])
// 防止狂点下一首或上一首造成封面与歌曲不匹配的问题
useEffect(() => {
const realCover = resizeImage(track?.al.picUrl ?? '', 'lg')
if (cover !== realCover) setCover(realCover)
}, [cover, track?.al.picUrl])
const onLoad = () => {
const passedTime = Date.now() - animationStartTime.current
controls.start({
opacity: 1,
transition: {
delay: passedTime > duration ? 0 : (duration - passedTime) / 1000,
},
const unsubscribe = subscribeKey(player, 'track', async () => {
const coverUrl = player.track?.al.picUrl
await controls.start({
opacity: 0,
transition: { duration: 0.2 },
})
setCover(coverUrl)
if (!coverUrl) return
const img = new Image()
img.onload = () => {
controls.start({
opacity: 1,
transition: { duration: 0.2 },
})
}
img.src = coverUrl
})
}
return unsubscribe
}, [])
return (
<motion.img
animate={controls}
transition={{ duration: duration / 1000, ease }}
className={cx('absolute inset-0 w-full')}
transition={{ ease }}
className='absolute inset-0 w-full'
src={cover}
onLoad={onLoad}
onClick={() => {
const id = track?.al.id
if (id) navigate(`/album/${id}`)

View file

@ -23,7 +23,7 @@ const Header = () => {
)}
>
<div className='flex'>
<div className='mr-2 h-4 w-1 bg-brand-700'></div>
<div className='mr-2 h-4 w-1 rounded-full bg-brand-700'></div>
{t`player.queue`}
</div>
<div className='flex'>
@ -117,11 +117,7 @@ const TrackList = ({ className }: { className?: string }) => {
<>
<div
className={css`
mask-image: linear-gradient(
to bottom,
transparent 22px,
black 42px
); // 顶部渐变遮罩
mask-image: linear-gradient(to bottom, transparent 22px, black 42px); // 顶部渐变遮罩
`}
>
<Virtuoso
@ -133,11 +129,7 @@ const TrackList = ({ className }: { className?: string }) => {
'no-scrollbar relative z-10 w-full overflow-auto',
className,
css`
mask-image: linear-gradient(
to top,
transparent 8px,
black 42px
); // 底部渐变遮罩
mask-image: linear-gradient(to top, transparent 8px, black 42px); // 底部渐变遮罩
`
)}
fixedItemHeight={76}

View file

@ -0,0 +1,5 @@
function Tooltip() {
return <></>
}
export default Tooltip

View file

@ -128,13 +128,15 @@ const SearchBox = () => {
const [searchText, setSearchText] = useState('')
const [isFocused, setIsFocused] = useState(false)
const { t } = useTranslation()
const inputRef = useRef<HTMLInputElement>(null)
return (
<div className='relative'>
{/* Input */}
<div
onClick={() => inputRef.current?.focus()}
className={cx(
'app-region-no-drag flex items-center rounded-full bg-white/10 p-2.5 text-white/40 backdrop-blur-3xl',
'app-region-no-drag flex cursor-text items-center rounded-full bg-white/10 p-2.5 text-white/40 backdrop-blur-3xl',
css`
${bp.lg} {
min-width: 284px;
@ -144,6 +146,7 @@ const SearchBox = () => {
>
<Icon name='search' className='mr-2.5 h-7 w-7' />
<input
ref={inputRef}
placeholder={t`search.search`}
className={cx(
'flex-shrink bg-transparent font-medium placeholder:text-white/40 dark:text-white/80',

View file

@ -3,8 +3,9 @@ import Icon from '@/web/components/Icon'
import dayjs from 'dayjs'
import { useNavigate } from 'react-router-dom'
import useIsMobile from '@/web/hooks/useIsMobile'
import { ReactNode } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { ReactNode, useState } from 'react'
import { motion } from 'framer-motion'
import DescriptionViewer from '../DescriptionViewer'
const Info = ({
title,
@ -23,6 +24,7 @@ const Info = ({
}) => {
const navigate = useNavigate()
const isMobile = useIsMobile()
const [isOpenDescription, setIsOpenDescription] = useState(false)
return (
<div>
@ -72,12 +74,20 @@ const Info = ({
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className='line-clamp-3 mt-6 whitespace-pre-wrap text-14 font-bold dark:text-white/40'
className='line-clamp-3 mt-6 whitespace-pre-wrap text-14 font-bold transition-colors duration-300 dark:text-white/40 dark:hover:text-white/60'
dangerouslySetInnerHTML={{
__html: description,
}}
onClick={() => setIsOpenDescription(true)}
></motion.div>
)}
<DescriptionViewer
description={description || ''}
isOpen={isOpenDescription}
onClose={() => setIsOpenDescription(false)}
title={title || ''}
/>
</div>
)
}

View file

@ -22,14 +22,14 @@ const VideoCover = ({ source, onPlay }: { source?: string; onPlay?: () => void }
}, [source])
// Pause video cover when playing another video
const { playingVideoID } = useSnapshot(uiStates)
const { playingVideoID, isPauseVideos } = useSnapshot(uiStates)
useEffect(() => {
if (playingVideoID) {
if (playingVideoID || isPauseVideos) {
videoRef?.current?.pause()
} else {
videoRef?.current?.play()
}
}, [playingVideoID])
}, [playingVideoID, isPauseVideos])
return (
<motion.div

View file

@ -3,6 +3,8 @@ import useAppleMusicArtist from '@/web/api/hooks/useAppleMusicArtist'
import { cx, css } from '@emotion/css'
import { useTranslation } from 'react-i18next'
import i18next from 'i18next'
import { useState } from 'react'
import DescriptionViewer from '@/web/components/DescriptionViewer'
const ArtistInfo = ({ artist, isLoading }: { artist?: Artist; isLoading: boolean }) => {
const { t, i18n } = useTranslation()
@ -12,6 +14,10 @@ const ArtistInfo = ({ artist, isLoading }: { artist?: Artist; isLoading: boolean
artist?.id || 0
)
const [isOpenDescription, setIsOpenDescription] = useState(false)
const description =
artistFromApple?.artistBio?.[i18n.language.replace('-', '_')] || artist?.briefDesc
return (
<div>
{/* Name */}
@ -61,15 +67,23 @@ const ArtistInfo = ({ artist, isLoading }: { artist?: Artist; isLoading: boolean
) : (
<div
className={cx(
'line-clamp-5 mt-6 text-14 font-bold text-white/40',
css`
height: 86px;
`
'line-clamp-5 mt-6 overflow-hidden whitespace-pre-wrap text-14 font-bold text-white/40 transition-colors duration-500 hover:text-white/60'
// css`
// height: 86px;
// `
)}
onClick={() => setIsOpenDescription(true)}
>
{artistFromApple?.artistBio?.[i18n.language.replace('-', '_')] || artist?.briefDesc}
{description}
</div>
))}
<DescriptionViewer
description={description || ''}
isOpen={isOpenDescription}
onClose={() => setIsOpenDescription(false)}
title={artist?.name || ''}
/>
</div>
)
}

View file

@ -79,7 +79,7 @@ const CollectionTabs = ({ showBg }: { showBg: boolean }) => {
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className={cx(
'pointer-events-none fixed top-0 right-0 left-10 z-10 hidden lg:block',
'pointer-events-none fixed top-0 right-0 left-10 z-10',
css`
height: 230px;
background-repeat: repeat;

View file

@ -10,6 +10,7 @@ interface UIStates {
blurBackgroundImage: string | null
fullscreen: boolean
playingVideoID: number | null
isPauseVideos: boolean
}
const initUIStates: UIStates = {
@ -21,6 +22,7 @@ const initUIStates: UIStates = {
blurBackgroundImage: null,
fullscreen: false,
playingVideoID: null,
isPauseVideos: false,
}
window.ipcRenderer

View file

@ -112,8 +112,14 @@ input {
@apply outline-none;
}
*::selection {
@apply bg-brand-500 text-neutral-800;
}
a,
button {
button,
p,
div {
@apply cursor-default;
}

View file

@ -9,7 +9,7 @@ import {
storage,
} from '@/web/utils/common'
import { IpcChannels } from '@/shared/IpcChannels'
import { APIs } from '@/shared/CacheAPIs'
import { CacheAPIs } from '@/shared/CacheAPIs'
test('resizeImage', () => {
expect(resizeImage('https://test.com/test.jpg', 'xs')).toBe(
@ -50,12 +50,8 @@ test('formatDuration', () => {
expect(formatDuration(3600000, 'zh-CN', 'hh[hr] mm[min]')).toBe('1 小时')
expect(formatDuration(3600000, 'zh-TW', 'hh[hr] mm[min]')).toBe('1 小時')
expect(formatDuration(3700000, 'en', 'hh[hr] mm[min]')).toBe('1 hr 1 min')
expect(formatDuration(3700000, 'zh-CN', 'hh[hr] mm[min]')).toBe(
'1 小时 1 分钟'
)
expect(formatDuration(3700000, 'zh-TW', 'hh[hr] mm[min]')).toBe(
'1 小時 1 分鐘'
)
expect(formatDuration(3700000, 'zh-CN', 'hh[hr] mm[min]')).toBe('1 小时 1 分钟')
expect(formatDuration(3700000, 'zh-TW', 'hh[hr] mm[min]')).toBe('1 小時 1 分鐘')
expect(formatDuration(0)).toBe('0:00')
expect(formatDuration(0, 'en', 'hh[hr] mm[min]')).toBe('0 min')
@ -67,7 +63,7 @@ describe('cacheCoverColor', () => {
vi.stubGlobal('ipcRenderer', {
send: (channel: IpcChannels, ...args: any[]) => {
expect(channel).toBe(IpcChannels.CacheCoverColor)
expect(args[0].api).toBe(APIs.CoverColor)
expect(args[0].api).toBe(CacheAPIs.CoverColor)
expect(args[0].query).toEqual({
id: '109951165911363',
color: '#fff',
@ -169,7 +165,7 @@ describe('getCoverColor', () => {
vi.stubGlobal('ipcRenderer', {
sendSync: (channel: IpcChannels, ...args: any[]) => {
expect(channel).toBe(IpcChannels.GetApiCache)
expect(args[0].api).toBe(APIs.CoverColor)
expect(args[0].api).toBe(CacheAPIs.CoverColor)
expect(args[0].query).toEqual({
id: '109951165911363',
})

View file

@ -1,7 +1,7 @@
import { IpcChannels } from '@/shared/IpcChannels'
import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration'
import { APIs } from '@/shared/CacheAPIs'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { average } from 'color.js'
import { colord } from 'colord'
import { supportedLanguages } from '../i18n/i18n'
@ -11,10 +11,7 @@ import { supportedLanguages } from '../i18n/i18n'
* @param {string} url URL
* @param {'xs'|'sm'|'md'|'lg'} size - 128px | 256px | 512px | 1024px
*/
export function resizeImage(
url: string,
size: 'xs' | 'sm' | 'md' | 'lg'
): string {
export function resizeImage(url: string, size: 'xs' | 'sm' | 'md' | 'lg'): string {
if (!url) return ''
const sizeMap = {
@ -87,9 +84,7 @@ export function formatDuration(
const seconds = time.seconds().toString().padStart(2, '0')
if (format === 'hh:mm:ss') {
return hours !== '0'
? `${hours}:${mins.padStart(2, '0')}:${seconds}`
: `${mins}:${seconds}`
return hours !== '0' ? `${hours}:${mins.padStart(2, '0')}:${seconds}` : `${mins}:${seconds}`
} else {
const units = {
'en-US': {
@ -107,23 +102,17 @@ export function formatDuration(
} as const
return hours !== '0'
? `${hours} ${units[locale].hours}${
mins === '0' ? '' : ` ${mins} ${units[locale].mins}`
}`
? `${hours} ${units[locale].hours}${mins === '0' ? '' : ` ${mins} ${units[locale].mins}`}`
: `${mins} ${units[locale].mins}`
}
}
export function scrollToTop(smooth = false) {
document
.querySelector('#main')
?.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' })
document.querySelector('#main')?.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' })
}
export function scrollToBottom(smooth = false) {
document
.querySelector('#main')
?.scrollTo({ top: 100000, behavior: smooth ? 'smooth' : 'auto' })
document.querySelector('#main')?.scrollTo({ top: 100000, behavior: smooth ? 'smooth' : 'auto' })
}
export async function getCoverColor(coverUrl: string) {
@ -137,7 +126,7 @@ export async function getCoverColor(coverUrl: string) {
const colorFromCache: string | undefined = await window.ipcRenderer?.invoke(
IpcChannels.GetApiCache,
{
api: APIs.CoverColor,
api: CacheAPIs.CoverColor,
query: {
id,
},
@ -177,12 +166,9 @@ export async function calcCoverColor(coverUrl: string) {
}
export const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
export const isSafari = /^((?!chrome|android).)*safari/i.test(
navigator.userAgent
)
export const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
export const isPWA =
(navigator as any).standalone ||
window.matchMedia('(display-mode: standalone)').matches
(navigator as any).standalone || window.matchMedia('(display-mode: standalone)').matches
export const isIosPwa = isIOS && isPWA && isSafari
export const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))

View file

@ -28,10 +28,7 @@ interface TrackListSource {
type: TrackListSourceType
id: number
}
interface RemoteDevice {
protocol: 'airplay' | 'chromecast'
id: string
}
export enum Mode {
TrackList = 'trackList',
FM = 'fm',
@ -62,7 +59,6 @@ export class Player {
fmTrackList: TrackID[] = []
shuffle: boolean = false
fmTrack: Track | null = null
remoteDevice: RemoteDevice | null = null
init(params: { [key: string]: any }) {
if (params._track) this._track = params._track
@ -173,10 +169,6 @@ export class Player {
window.ipcRenderer?.send(IpcChannels.Repeat, { mode: this._repeatMode })
}
get _isAirplay() {
return this.remoteDevice?.protocol === 'airplay'
}
private async _initFM() {
if (this.fmTrackList.length === 0) await this._loadMoreFMTracks()
@ -194,27 +186,9 @@ export class Player {
}
private async _setupProgressInterval() {
if (this.remoteDevice === null) {
// Howler
this._progressInterval = setInterval(() => {
if (this.state === State.Playing) this._progress = _howler.seek()
}, 1000)
} else if (this._isAirplay) {
// Airplay
// let isFetchAirplayPlayingInfo = false
// this._progressInterval = setInterval(async () => {
// if (isFetchAirplayPlayingInfo) return
// isFetchAirplayPlayingInfo = true
// const playingInfo = await window?.ipcRenderer?.invoke(
// 'airplay-get-playing',
// { deviceID: this.remoteDevice?.id }
// )
// if (playingInfo) {
// this._progress = playingInfo.position || 0
// }
// isFetchAirplayPlayingInfo = false
// }, 1000)
}
this._progressInterval = setInterval(() => {
if (this.state === State.Playing) this._progress = _howler.seek()
}, 1000)
}
private async _scrobble() {
@ -285,23 +259,12 @@ export class Player {
return
}
if (this.trackID !== id) return
if (this._isAirplay) {
this._playAudioViaAirplay(audio)
return
} else {
this._playAudioViaHowler(audio, id, autoplay)
}
this._playAudioViaHowler(audio, id, autoplay)
}
private async _playAudioViaHowler(
audio: string,
id: number,
autoplay: boolean = true
) {
private async _playAudioViaHowler(audio: string, id: number, autoplay: boolean = true) {
Howler.unload()
const url = audio.includes('?')
? `${audio}&dash-id=${id}`
: `${audio}?dash-id=${id}`
const url = audio.includes('?') ? `${audio}&dash-id=${id}` : `${audio}?dash-id=${id}`
const howler = new Howl({
src: [url],
format: ['mp3', 'flac', 'webm'],
@ -325,18 +288,6 @@ export class Player {
}
}
private async _playAudioViaAirplay(audio: string) {
// if (!this._isAirplay) {
// console.log('No airplay device selected')
// return
// }
// const result = await window.ipcRenderer?.invoke('airplay-play-url', {
// deviceID: this.remoteDevice?.id,
// url: audio,
// })
// console.log(result)
}
private _howlerOnEndCallback() {
if (this.mode !== Mode.FM && this.repeatMode === RepeatMode.One) {
_howler.seek(0)
@ -367,18 +318,14 @@ export class Player {
if (this.fmTrackList.length === 0) await this._loadMoreFMTracks()
this._playTrack()
this.fmTrackList.length <= 1
? await this._loadMoreFMTracks()
: this._loadMoreFMTracks()
this.fmTrackList.length <= 1 ? await this._loadMoreFMTracks() : this._loadMoreFMTracks()
prefetchNextTrack()
}
private async _loadMoreFMTracks() {
if (this.fmTrackList.length <= 5) {
const response = await fetchPersonalFMWithReactQuery()
const ids = (response?.data?.map(r => r.id) ?? []).filter(
r => !this.fmTrackList.includes(r)
)
const ids = (response?.data?.map(r => r.id) ?? []).filter(r => !this.fmTrackList.includes(r))
this.fmTrackList.push(...ids)
}
}
@ -476,9 +423,7 @@ export class Player {
this._setStateToLoading()
this.mode = Mode.TrackList
this.trackList = list
this._trackIndex = autoPlayTrackID
? list.findIndex(t => t === autoPlayTrackID)
: 0
this._trackIndex = autoPlayTrackID ? list.findIndex(t => t === autoPlayTrackID) : 0
this._playTrack()
}
@ -552,10 +497,7 @@ export class Player {
async playFM() {
this._setStateToLoading()
this.mode = Mode.FM
if (
this.fmTrackList.length > 0 &&
this.fmTrack?.id === this.fmTrackList[0]
) {
if (this.fmTrackList.length > 0 && this.fmTrack?.id === this.fmTrackList[0]) {
this._track = this.fmTrack
this._playAudio()
} else {
@ -584,29 +526,12 @@ export class Player {
this._playTrack()
}
async switchToThisComputer() {
this.remoteDevice = null
clearInterval(this._progressInterval)
this._setupProgressInterval()
}
async switchToAirplayDevice(deviceID: string) {
this.remoteDevice = {
protocol: 'airplay',
id: deviceID,
}
clearInterval(this._progressInterval)
this._setupProgressInterval()
}
private async _initMediaSession() {
console.log('init')
if ('mediaSession' in navigator === false) return
navigator.mediaSession.setActionHandler('play', () => this.play())
navigator.mediaSession.setActionHandler('pause', () => this.pause())
navigator.mediaSession.setActionHandler('previoustrack', () =>
this.prevTrack()
)
navigator.mediaSession.setActionHandler('previoustrack', () => this.prevTrack())
navigator.mediaSession.setActionHandler('nexttrack', () => this.nextTrack())
navigator.mediaSession.setActionHandler('seekto', event => {
if (event.seekTime) this.progress = event.seekTime

365
pnpm-lock.yaml generated
View file

@ -24,19 +24,16 @@ importers:
specifiers:
'@fastify/cookie': ^8.3.0
'@fastify/http-proxy': ^8.4.0
'@fastify/multipart': ^7.4.0
'@prisma/client': ^4.8.1
'@prisma/engines': ^4.9.0
'@sentry/electron': ^3.0.7
'@types/better-sqlite3': ^7.6.3
'@types/cookie-parser': ^1.4.3
'@types/express': ^4.17.15
'@types/express-fileupload': ^1.2.3
'@vitest/ui': ^0.20.3
NeteaseCloudMusicApi: ^4.8.7
'@yimura/scraper': ^1.2.4
NeteaseCloudMusicApi: ^4.8.9
axios: ^1.2.1
better-sqlite3: 8.0.1
change-case: ^4.1.2
compare-versions: ^4.1.3
connect-history-api-fallback: ^2.0.0
cookie-parser: ^1.4.6
cross-env: ^7.0.3
dotenv: ^16.0.3
electron: ^22.0.0
@ -47,8 +44,6 @@ importers:
electron-releases: ^3.1171.0
electron-store: ^8.1.0
esbuild: ^0.16.10
express: ^4.18.2
express-fileupload: ^1.4.0
fast-folder-size: ^1.7.1
minimist: ^1.2.7
music-metadata: ^8.1.0
@ -57,34 +52,34 @@ importers:
picocolors: ^1.0.0
prettier: '*'
pretty-bytes: ^6.0.0
prisma: ^4.8.1
tsx: '*'
type-fest: ^3.5.0
typescript: '*'
vitest: ^0.20.3
wait-on: ^7.0.1
ytdl-core: ^4.11.2
ytsr: ^3.8.0
zx: ^7.1.1
dependencies:
'@fastify/cookie': 8.3.0
'@fastify/http-proxy': 8.4.0
'@fastify/multipart': 7.4.0
'@prisma/client': 4.8.1_prisma@4.8.1
'@prisma/engines': 4.9.0
'@sentry/electron': 3.0.7
NeteaseCloudMusicApi: 4.8.7
better-sqlite3: 8.0.1
'@yimura/scraper': 1.2.4
NeteaseCloudMusicApi: 4.8.9
change-case: 4.1.2
compare-versions: 4.1.3
connect-history-api-fallback: 2.0.0
cookie-parser: 1.4.6
electron-log: 4.4.8
electron-store: 8.1.0
express: 4.18.2
fast-folder-size: 1.7.1
pretty-bytes: 6.0.0
type-fest: 3.5.0
prisma: 4.8.1
ytdl-core: 4.11.2
ytsr: 3.8.0
zx: 7.1.1
devDependencies:
'@types/better-sqlite3': 7.6.3
'@types/cookie-parser': 1.4.3
'@types/express': 4.17.15
'@types/express-fileupload': 1.2.3
'@vitest/ui': 0.20.3
axios: 1.2.1
cross-env: 7.0.3
@ -95,7 +90,6 @@ importers:
electron-rebuild: 3.2.9
electron-releases: 3.1171.0
esbuild: 0.16.10
express-fileupload: 1.4.0
minimist: 1.2.7
music-metadata: 8.1.0
open-cli: 7.1.0
@ -3180,6 +3174,13 @@ packages:
pkg-up: 3.1.0
dev: false
/@fastify/busboy/1.2.1:
resolution: {integrity: sha512-7PQA7EH43S0CxcOa9OeAnaeA0oQ+e/DHNPZwSQM9CQHW76jle5+OvLdibRp/Aafs9KXbLhxyjOTkRjWUbQEd3Q==}
engines: {node: '>=14'}
dependencies:
text-decoding: 1.0.0
dev: false
/@fastify/cookie/8.3.0:
resolution: {integrity: sha512-P9hY9GO11L20TnZ33XN3i0bt+3x0zaT7S0ohAzWO950E9PB2xnNhLYzPFJIGFi5AVN0yr5+/iZhWxeYvR6KCzg==}
dependencies:
@ -3211,6 +3212,19 @@ packages:
- utf-8-validate
dev: false
/@fastify/multipart/7.4.0:
resolution: {integrity: sha512-jl8KCMOjzniAMnF2/VdYFhGB03Oqtl24plxcnsKpdnRLu/ihVz4cNPz9bPn8mLUQW4r3dUlh6emINtZdJczkbg==}
dependencies:
'@fastify/busboy': 1.2.1
'@fastify/deepmerge': 1.1.0
'@fastify/error': 3.0.0
end-of-stream: 1.4.4
fastify-plugin: 4.5.0
hexoid: 1.0.0
secure-json-parse: 2.5.0
stream-wormhole: 1.1.0
dev: false
/@fastify/reply-from/8.3.1:
resolution: {integrity: sha512-fRByAvTMXuBuYIimcinukOB3YdmqtYPeoybXIBNY0aPVgetHkmCVffBo/M4pEOib9Pes8wuoYL4VawI65aHl4w==}
dependencies:
@ -3681,6 +3695,11 @@ packages:
resolution: {integrity: sha512-93tctjNXcIS+i/e552IO6tqw17sX8liivv8WX9lDMCpEEe3ci+nT9F+1oHtAafqruXLepKF80i/D20Mm+ESlOw==}
requiresBuild: true
/@prisma/engines/4.9.0:
resolution: {integrity: sha512-t1pt0Gsp+HcgPJrHFc+d/ZSAaKKWar2G/iakrE07yeKPNavDP3iVKPpfXP22OTCHZUWf7OelwKJxQgKAm5hkgw==}
requiresBuild: true
dev: false
/@remix-run/router/1.2.1:
resolution: {integrity: sha512-XiY0IsyHR+DXYS5vBxpoBe/8veTeoRpMHP+vDosLZxL5bnpetzI0igkxkLZS235ldLzyfkxF+2divEwWHP3vMQ==}
engines: {node: '>=14'}
@ -5571,25 +5590,6 @@ packages:
resolution: {integrity: sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==}
dev: true
/@types/better-sqlite3/7.6.3:
resolution: {integrity: sha512-YS64N9SNDT/NAvou3QNdzAu3E2om/W/0dhORimtPGLef+zSK5l1vDzfsWb4xgXOgfhtOI5ZDTRxnvRPb22AIVQ==}
dependencies:
'@types/node': 18.6.4
dev: true
/@types/body-parser/1.19.2:
resolution: {integrity: sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==}
dependencies:
'@types/connect': 3.4.35
'@types/node': 18.6.4
dev: true
/@types/busboy/0.3.2:
resolution: {integrity: sha512-iEvdm9Z9KdSs/ozuh1Z7ZsXrOl8F4M/CLMXPZHr3QuJ4d6Bjn+HBMC5EMKpwpAo8oi8iK9GZfFoHaIMrrZgwVw==}
dependencies:
'@types/node': 18.6.4
dev: true
/@types/cacheable-request/6.0.2:
resolution: {integrity: sha512-B3xVo+dlKM6nnKTcmm5ZtY/OL8bOAOd2Olee9M1zft65ox50OzjEHW91sDiU9j6cvW8Ejg1/Qkf4xd2kugApUA==}
dependencies:
@ -5613,18 +5613,6 @@ packages:
resolution: {integrity: sha512-KnRanxnpfpjUTqTCXslZSEdLfXExwgNxYPdiO2WGUj8+HDjFi8R3k5RVKPeSCzLjCcshCAtVO2QBbVuAV4kTnw==}
dev: true
/@types/connect/3.4.35:
resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==}
dependencies:
'@types/node': 18.6.4
dev: true
/@types/cookie-parser/1.4.3:
resolution: {integrity: sha512-CqSKwFwefj4PzZ5n/iwad/bow2hTCh0FlNAeWLtQM3JA/NX/iYagIpWG2cf1bQKQ2c9gU2log5VUCrn7LDOs0w==}
dependencies:
'@types/express': 4.17.15
dev: true
/@types/debug/4.1.7:
resolution: {integrity: sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==}
dependencies:
@ -5657,30 +5645,6 @@ packages:
resolution: {integrity: sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==}
dev: true
/@types/express-fileupload/1.2.3:
resolution: {integrity: sha512-TaMmhgnWr5Nb/bJ7zPSNSTBMQTX7vwKzv8+HqX1vZtytXw/DzcvX/IzjENO2BdzttJfmWofzi0aRio6cXWPuAw==}
dependencies:
'@types/busboy': 0.3.2
'@types/express': 4.17.15
dev: true
/@types/express-serve-static-core/4.17.31:
resolution: {integrity: sha512-DxMhY+NAsTwMMFHBTtJFNp5qiHKJ7TeqOo23zVEM9alT1Ml27Q3xcTH0xwxn7Q0BbMcVEJOs/7aQtUWupUQN3Q==}
dependencies:
'@types/node': 18.6.4
'@types/qs': 6.9.7
'@types/range-parser': 1.2.4
dev: true
/@types/express/4.17.15:
resolution: {integrity: sha512-Yv0k4bXGOH+8a+7bELd2PqHQsuiANB+A8a4gnQrkRWzrkKlb6KHaVvyXhqs04sVW/OWlbPyYxRgYlIXLfrufMQ==}
dependencies:
'@types/body-parser': 1.19.2
'@types/express-serve-static-core': 4.17.31
'@types/qs': 6.9.7
'@types/serve-static': 1.15.0
dev: true
/@types/fs-extra/9.0.13:
resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==}
dependencies:
@ -5691,7 +5655,7 @@ packages:
requiresBuild: true
dependencies:
'@types/minimatch': 3.0.5
'@types/node': 18.6.4
'@types/node': 18.11.17
dev: true
/@types/graceful-fs/4.1.5:
@ -5780,10 +5744,6 @@ packages:
'@types/unist': 2.0.6
dev: true
/@types/mime/3.0.1:
resolution: {integrity: sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==}
dev: true
/@types/minimatch/3.0.5:
resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==}
dev: true
@ -5858,10 +5818,6 @@ packages:
resolution: {integrity: sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==}
dev: true
/@types/range-parser/1.2.4:
resolution: {integrity: sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==}
dev: true
/@types/react-dom/18.0.6:
resolution: {integrity: sha512-/5OFZgfIPSwy+YuIBP/FgJnQnsxhZhjjrnxudMddeblOouIodEQ75X14Rr4wGSG/bknL+Omy9iWlLo1u/9GzAA==}
dependencies:
@ -5892,13 +5848,6 @@ packages:
resolution: {integrity: sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==}
dev: true
/@types/serve-static/1.15.0:
resolution: {integrity: sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==}
dependencies:
'@types/mime': 3.0.1
'@types/node': 18.6.4
dev: true
/@types/source-list-map/0.1.2:
resolution: {integrity: sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA==}
dev: true
@ -6287,6 +6236,10 @@ packages:
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
dev: true
/@yimura/scraper/1.2.4:
resolution: {integrity: sha512-lHx/NqqZBOuDyYdUbXIigLGKKm2+Wtrx4rxGJ1dObZhOyth3CzFZbb1/SykVVzxxGYkFkhD9HHf74UWelaP2mw==}
dev: false
/NeteaseCloudMusicApi/4.8.7:
resolution: {integrity: sha512-f+Z/lar+IOcrzGeDKHNQaTnlNE7nLRiD9Nb/KoLmKC32aUnnCerFTFV2/ZWlEC4YokP/4mXOZXvo8wXA8PQhVA==}
engines: {node: '>=12'}
@ -6307,6 +6260,26 @@ packages:
- supports-color
dev: false
/NeteaseCloudMusicApi/4.8.9:
resolution: {integrity: sha512-3YcbljD8nGn0KsjoPq6pBxZSuu3Sk4M3c8ZZhdvEAL+P+1noYftq/ZhA+CY0uQXLMkgbHRpLapBdGXMXhhB6/Q==}
engines: {node: '>=12'}
hasBin: true
dependencies:
axios: 1.2.2
express: 4.18.2
express-fileupload: 1.4.0
md5: 2.3.0
music-metadata: 7.12.6
pac-proxy-agent: 5.0.0
qrcode: 1.5.1
safe-decode-uri-component: 1.2.1
tunnel: 0.0.6
yargs: 17.5.1
transitivePeerDependencies:
- debug
- supports-color
dev: false
/abab/2.0.6:
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
dev: true
@ -7172,6 +7145,7 @@ packages:
/base64-js/1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
dev: true
/better-opn/2.1.1:
resolution: {integrity: sha512-kIPXZS5qwyKiX/HcRvDYfmBQUa8XP17I0mYZZ0y4UhpYOSvtsLHDYqmomS+Mj20aDvD3knEiQ0ecQy2nhio3yA==}
@ -7180,14 +7154,6 @@ packages:
open: 7.4.2
dev: true
/better-sqlite3/8.0.1:
resolution: {integrity: sha512-JhTZjpyapA1icCEjIZB4TSSgkGdFgpWZA2Wszg7Cf4JwJwKQmbvuNnJBeR+EYG/Z29OXvR4G//Rbg31BW/Z7Yg==}
requiresBuild: true
dependencies:
bindings: 1.5.0
prebuild-install: 7.1.1
dev: false
/big-integer/1.6.51:
resolution: {integrity: sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==}
engines: {node: '>=0.6'}
@ -7217,6 +7183,8 @@ packages:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
dependencies:
file-uri-to-path: 1.0.0
dev: true
optional: true
/bl/4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
@ -7224,6 +7192,7 @@ packages:
buffer: 5.7.1
inherits: 2.0.4
readable-stream: 3.6.0
dev: true
/bl/5.0.0:
resolution: {integrity: sha512-8vxFNZ0pflFfi0WXA3WQXlj6CaMEwsmh63I1CNp0q+wWv8sD0ARx1KovSQd0l2GkwrMIOyedq0EF1FxI+RCZLQ==}
@ -7465,6 +7434,7 @@ packages:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
dev: true
/buffer/6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
@ -7526,6 +7496,7 @@ packages:
engines: {node: '>=10.16.0'}
dependencies:
streamsearch: 1.1.0
dev: false
/bytes/3.0.0:
resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==}
@ -7884,6 +7855,7 @@ packages:
/chownr/1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
dev: true
/chownr/2.0.0:
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
@ -8227,11 +8199,6 @@ packages:
semver: 7.3.7
dev: false
/connect-history-api-fallback/2.0.0:
resolution: {integrity: sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==}
engines: {node: '>=0.8'}
dev: false
/console-browserify/1.2.0:
resolution: {integrity: sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==}
dev: true
@ -8267,22 +8234,9 @@ packages:
dependencies:
safe-buffer: 5.1.2
/cookie-parser/1.4.6:
resolution: {integrity: sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==}
engines: {node: '>= 0.8.0'}
dependencies:
cookie: 0.4.1
cookie-signature: 1.0.6
dev: false
/cookie-signature/1.0.6:
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
/cookie/0.4.1:
resolution: {integrity: sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==}
engines: {node: '>= 0.6'}
dev: false
/cookie/0.4.2:
resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==}
engines: {node: '>= 0.6'}
@ -8722,6 +8676,7 @@ packages:
engines: {node: '>=10'}
dependencies:
mimic-response: 3.1.0
dev: true
/dedent/0.7.0:
resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==}
@ -8741,11 +8696,6 @@ packages:
type-detect: 4.0.8
dev: true
/deep-extend/0.6.0:
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
engines: {node: '>=4.0.0'}
dev: false
/deep-is/0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@ -8876,6 +8826,7 @@ packages:
/detect-libc/2.0.1:
resolution: {integrity: sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==}
engines: {node: '>=8'}
dev: true
/detect-node/2.1.0:
resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==}
@ -10236,16 +10187,12 @@ packages:
- supports-color
dev: true
/expand-template/2.0.3:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
dev: false
/express-fileupload/1.4.0:
resolution: {integrity: sha512-RjzLCHxkv3umDeZKeFeMg8w7qe0V09w3B7oGZprr/oO2H/ISCgNzuqzn7gV3HRWb37GjRk429CCpSLS2KNTqMQ==}
engines: {node: '>=12.0.0'}
dependencies:
busboy: 1.6.0
dev: false
/express/4.18.2:
resolution: {integrity: sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==}
@ -10373,17 +10320,6 @@ packages:
- supports-color
dev: true
/fast-glob/3.2.11:
resolution: {integrity: sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==}
engines: {node: '>=8.6.0'}
dependencies:
'@nodelib/fs.stat': 2.0.5
'@nodelib/fs.walk': 1.2.8
glob-parent: 5.1.2
merge2: 1.4.1
micromatch: 4.0.5
dev: false
/fast-glob/3.2.12:
resolution: {integrity: sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==}
engines: {node: '>=8.6.0'}
@ -10393,7 +10329,6 @@ packages:
glob-parent: 5.1.2
merge2: 1.4.1
micromatch: 4.0.5
dev: true
/fast-json-parse/1.0.3:
resolution: {integrity: sha512-FRWsaZRWEJ1ESVNbDWmsAlqDk96gPQezzLghafp5J4GUKjbCz3OkAHuZs5TuPEtkbVQERysLp9xv6c24fBm8Aw==}
@ -10579,6 +10514,8 @@ packages:
/file-uri-to-path/1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
dev: true
optional: true
/file-uri-to-path/2.0.0:
resolution: {integrity: sha512-hjPFI8oE/2iQPVe4gbrJ73Pp+Xfub2+WI2LlXDbsaJBwT5wuMh35WNWVYYTpnz895shtwfyutMFLFywpQAFdLg==}
@ -10856,10 +10793,6 @@ packages:
readable-stream: 2.3.7
dev: true
/fs-constants/1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
dev: false
/fs-extra/10.1.0:
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
engines: {node: '>=12'}
@ -11096,10 +11029,6 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/github-from-package/0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
dev: false
/github-slugger/1.4.0:
resolution: {integrity: sha512-w0dzqw/nt51xMVmlaV1+JRzN+oCa1KfcgGEWhxUG16wbdA+Xnt/yoFO8Z8x/V82ZcZ0wy6ln9QDup5avbhiDhQ==}
dev: true
@ -11229,7 +11158,7 @@ packages:
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dependencies:
dir-glob: 3.0.1
fast-glob: 3.2.11
fast-glob: 3.2.12
ignore: 5.2.0
merge2: 1.4.1
slash: 4.0.0
@ -11496,6 +11425,11 @@ packages:
readable-stream: 3.6.0
dev: false
/hexoid/1.0.0:
resolution: {integrity: sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==}
engines: {node: '>=8'}
dev: false
/hey-listen/1.0.8:
resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==}
dev: false
@ -11813,10 +11747,6 @@ packages:
/inherits/2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
/ini/1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
dev: false
/inline-style-parser/0.1.1:
resolution: {integrity: sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==}
dev: true
@ -12915,6 +12845,14 @@ packages:
readable-stream: 3.6.0
dev: true
/m3u8stream/0.8.6:
resolution: {integrity: sha512-LZj8kIVf9KCphiHmH7sbFQTVe4tOemb202fWwvJwR9W5ENW/1hxJN6ksAWGhQgSBSa3jyWhnjKU1Fw1GaOdbyA==}
engines: {node: '>=12'}
dependencies:
miniget: 4.2.2
sax: 1.2.4
dev: false
/magic-string/0.25.9:
resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==}
dependencies:
@ -13299,6 +13237,7 @@ packages:
/mimic-response/3.1.0:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'}
dev: true
/min-document/2.19.0:
resolution: {integrity: sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==}
@ -13311,6 +13250,11 @@ packages:
engines: {node: '>=4'}
dev: true
/miniget/4.2.2:
resolution: {integrity: sha512-a7voNL1N5lDMxvTMExOkg+Fq89jM2vY8pAi9ZEWzZtfNmdfP6RXkvUtFnCAXoCv2T9k1v/fUJVaAEuepGcvLYA==}
engines: {node: '>=12'}
dev: false
/minimalistic-assert/1.0.1:
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
dev: true
@ -13426,10 +13370,6 @@ packages:
is-extendable: 1.0.1
dev: true
/mkdirp-classic/0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
dev: false
/mkdirp/0.5.6:
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
hasBin: true
@ -13557,10 +13497,6 @@ packages:
- supports-color
dev: true
/napi-build-utils/1.0.2:
resolution: {integrity: sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==}
dev: false
/natural-compare/1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
dev: true
@ -13597,6 +13533,7 @@ packages:
engines: {node: '>=10'}
dependencies:
semver: 7.3.7
dev: true
/node-addon-api/1.7.2:
resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==}
@ -14709,25 +14646,6 @@ packages:
posthtml-render: 1.4.0
dev: true
/prebuild-install/7.1.1:
resolution: {integrity: sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==}
engines: {node: '>=10'}
hasBin: true
dependencies:
detect-libc: 2.0.1
expand-template: 2.0.3
github-from-package: 0.0.0
minimist: 1.2.7
mkdirp-classic: 0.5.3
napi-build-utils: 1.0.2
node-abi: 3.24.0
pump: 3.0.0
rc: 1.2.8
simple-get: 4.0.1
tar-fs: 2.1.1
tunnel-agent: 0.6.0
dev: false
/prelude-ls/1.1.2:
resolution: {integrity: sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==}
engines: {node: '>= 0.8.0'}
@ -15072,16 +14990,6 @@ packages:
webpack: 4.46.0
dev: true
/rc/1.2.8:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
dependencies:
deep-extend: 0.6.0
ini: 1.3.8
minimist: 1.2.7
strip-json-comments: 2.0.1
dev: false
/react-docgen-typescript/2.2.2_typescript@4.7.4:
resolution: {integrity: sha512-tvg2ZtOpOi6QDwsb3GZhOjDkkX0h8Z2gipvTg6OVMUyoYoURhEiRNePT8NZItTVCDh39JJHnLdfCOkzoLbFnTg==}
peerDependencies:
@ -15894,7 +15802,6 @@ packages:
/sax/1.2.4:
resolution: {integrity: sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==}
dev: true
/saxes/6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
@ -16138,18 +16045,6 @@ packages:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
dev: true
/simple-concat/1.0.1:
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
dev: false
/simple-get/4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
dependencies:
decompress-response: 6.0.0
once: 1.4.0
simple-concat: 1.0.1
dev: false
/simple-update-notifier/1.1.0:
resolution: {integrity: sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==}
engines: {node: '>=8.10.0'}
@ -16525,9 +16420,15 @@ packages:
resolution: {integrity: sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==}
dev: true
/stream-wormhole/1.1.0:
resolution: {integrity: sha512-gHFfL3px0Kctd6Po0M8TzEvt3De/xu6cnRrjlfYNhwbhLPLwigI2t1nc6jrzNuaYg5C4YF78PPFuQPzRiqn9ew==}
engines: {node: '>=4.0.0'}
dev: false
/streamsearch/1.1.0:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'}
dev: false
/strict-uri-encode/1.1.0:
resolution: {integrity: sha512-R3f198pcvnB+5IpnBlRkphuE9n46WyVl8I39W/ZUTZLz4nqSP/oLYUrcnJrw462Ds8he4YKMov2efsTIw1BDGQ==}
@ -16678,11 +16579,6 @@ packages:
min-indent: 1.0.1
dev: true
/strip-json-comments/2.0.1:
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
engines: {node: '>=0.10.0'}
dev: false
/strip-json-comments/3.1.1:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
@ -16901,26 +16797,6 @@ packages:
engines: {node: '>=6'}
dev: true
/tar-fs/2.1.1:
resolution: {integrity: sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==}
dependencies:
chownr: 1.1.4
mkdirp-classic: 0.5.3
pump: 3.0.0
tar-stream: 2.2.0
dev: false
/tar-stream/2.2.0:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
dependencies:
bl: 4.1.0
end-of-stream: 1.4.4
fs-constants: 1.0.0
inherits: 2.0.4
readable-stream: 3.6.0
dev: false
/tar/6.1.11:
resolution: {integrity: sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==}
engines: {node: '>= 10'}
@ -17082,6 +16958,10 @@ packages:
minimatch: 3.1.2
dev: true
/text-decoding/1.0.0:
resolution: {integrity: sha512-/0TJD42KDnVwKmDK6jj3xP7E2MG7SHAOG4tyTgyUCRPdHwvkquYNLEQltmdMa3owq3TkddCVcTsoctJI8VQNKA==}
dev: false
/text-table/0.2.0:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
dev: true
@ -17378,12 +17258,6 @@ packages:
resolution: {integrity: sha512-JVa5ijo+j/sOoHGjw0sxw734b1LhBkQ3bvUGNdxnVXDCX81Yx7TFgnZygxrIIWn23hbfTaMYLwRmAxFyDuFmIw==}
dev: true
/tunnel-agent/0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
dependencies:
safe-buffer: 5.2.1
dev: false
/tunnel/0.0.6:
resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==}
engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'}
@ -17503,11 +17377,6 @@ packages:
resolution: {integrity: sha512-pRS+/yrW5TjPPHNOvxhbNZexr2bS63WjrMU8a+VzEBhUi9Tz1pZeD+vQz3ut0svZ46P+SRqMEPnJmk2XnvNzTw==}
engines: {node: '>=12.20'}
/type-fest/3.5.0:
resolution: {integrity: sha512-bI3zRmZC8K0tUz1HjbIOAGQwR2CoPQG68N5IF7gm0LBl8QSNXzkmaWnkWccCUL5uG9mCsp4sBwC8SBrNSISWew==}
engines: {node: '>=14.16'}
dev: false
/type-is/1.6.18:
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
engines: {node: '>= 0.6'}
@ -18876,6 +18745,22 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
/ytdl-core/4.11.2:
resolution: {integrity: sha512-D939t9b4ZzP3v0zDvehR2q+KgG97UTgrTKju3pOPGQcXtl4W6W5z0EpznzcJFu+OOpl7S7Ge8hv8zU65QnxYug==}
engines: {node: '>=12'}
dependencies:
m3u8stream: 0.8.6
miniget: 4.2.2
sax: 1.2.4
dev: false
/ytsr/3.8.0:
resolution: {integrity: sha512-R+RfYXvBBMAr2e4OxrQ5SBv5x/Mdhmcj1Q8TH0f2HK5d2jbhHOtK4BdzPvLriA6MDoMwqqX04GD8Rpf9UNtSTg==}
engines: {node: '>=8'}
dependencies:
miniget: 4.2.2
dev: false
/zwitch/1.0.5:
resolution: {integrity: sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==}
dev: true