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

@ -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