feat: updates

This commit is contained in:
qier222 2023-03-05 19:03:52 +08:00
parent 840a5b8e9b
commit cb0a809b16
No known key found for this signature in database
29 changed files with 1550 additions and 449 deletions

View file

@ -19,6 +19,7 @@ module.exports = {
buildDependenciesFromSource: false,
electronVersion,
forceCodeSigning: false,
afterPack: './scripts/copySQLite3.js',
publish: [
{
provider: 'github',
@ -118,17 +119,6 @@ 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',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 523 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 474 B

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 750 B

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 965 B

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 353 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -1,6 +1,5 @@
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'
@ -10,10 +9,11 @@ import youtube from '@/desktop/main/youtube'
import { CacheAPIs } from '@/shared/CacheAPIs'
import { FetchTracksResponse } from '@/shared/api/Track'
import store from '@/desktop/main/store'
import { db, Tables } from '@/desktop/main/db'
const getAudioFromCache = async (id: number) => {
// get from cache
const cache = await prisma.audio.findUnique({ where: { id } })
const cache = await db.find(Tables.Audio, id)
if (!cache) return
const audioFileName = `${cache.id}-${cache.bitRate}.${cache.format}`

View file

@ -1,9 +1,11 @@
import prisma from './prisma'
import { db, Tables } from './db'
import type { FetchTracksResponse } from '@/shared/api/Track'
import { app } from 'electron'
import log from './log'
import fs from 'fs'
import * as musicMetadata from 'music-metadata'
import { CacheAPIs, CacheAPIsParams, CacheAPIsResponse } from '@/shared/CacheAPIs'
import { CacheAPIs, CacheAPIsParams } from '@/shared/CacheAPIs'
import { TablesStructures } from './db'
import { FastifyReply } from 'fastify'
class Cache {
@ -11,12 +13,7 @@ class Cache {
//
}
async set<T extends CacheAPIs>(
api: T,
data: CacheAPIsResponse[T],
query: { [key: string]: string } = {}
) {
if (!data) return
async set(api: string, data: any, query: any = {}) {
switch (api) {
case CacheAPIs.UserPlaylist:
case CacheAPIs.UserAccount:
@ -26,104 +23,117 @@ class Cache {
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 })
if (!data) return
db.upsert(Tables.AccountData, {
id: api,
json: JSON.stringify(data),
updatedAt: Date.now(),
})
break
}
case CacheAPIs.Track: {
const res = data as CacheAPIsResponse[CacheAPIs.Track]
const res = data as FetchTracksResponse
if (!res.songs) return
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 tracks = res.songs.map(t => ({
id: t.id,
json: JSON.stringify(t),
updatedAt: Date.now(),
}))
db.upsertMany(Tables.Track, tracks)
break
}
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 })
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 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 })
db.upsert(Tables.Playlist, {
id: data.playlist.id,
json: JSON.stringify(data),
updatedAt: Date.now(),
})
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 })
db.upsert(Tables.Artist, {
id: data.artist.id,
json: JSON.stringify(data),
updatedAt: Date.now(),
})
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) } })
}
})
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 CacheAPIs.Lyric: {
if (!data.lrc) return
const id = Number(query.id)
const row = { id, json: JSON.stringify(data) }
await prisma.lyrics.upsert({ where: { id }, update: row, create: row })
db.upsert(Tables.Lyrics, {
id: query.id,
json: JSON.stringify(data),
updatedAt: Date.now(),
})
break
}
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.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
const id = data.id
const row = { id, json: JSON.stringify(data) }
await prisma.appleMusicAlbum.upsert({ where: { id }, update: row, create: row })
db.upsert(Tables.AppleMusicAlbum, {
id: data.id,
json: data.album ? JSON.stringify(data.album) : 'no',
updatedAt: Date.now(),
})
break
}
case CacheAPIs.AppleMusicArtist: {
if (!data) return
const id = data.id
const row = { id, json: JSON.stringify(data) }
await prisma.artist.upsert({ where: { id }, update: row, create: row })
db.upsert(Tables.AppleMusicArtist, {
id: data.id,
json: data.artist ? JSON.stringify(data.artist) : 'no',
updatedAt: Date.now(),
})
break
}
}
}
async get<T extends CacheAPIs>(
api: T,
query: CacheAPIsParams[T]
): Promise<CacheAPIsResponse[T] | undefined> {
get<T extends keyof CacheAPIsParams>(api: T, params: any): any {
switch (api) {
case CacheAPIs.UserPlaylist:
case CacheAPIs.UserAccount:
@ -132,18 +142,17 @@ class Cache {
case CacheAPIs.UserArtists:
case CacheAPIs.ListenedRecords:
case CacheAPIs.Likelist: {
const data = await prisma.accountData.findUnique({ where: { id: api } })
const data = db.find(Tables.AccountData, api)
if (data?.json) return JSON.parse(data.json)
break
}
case CacheAPIs.Track: {
const typedQuery = query as CacheAPIsParams[CacheAPIs.Track]
const ids: number[] = typedQuery?.ids.split(',').map((id: string) => Number(id))
const ids: number[] = params?.ids.split(',').map((id: string) => Number(id))
if (ids.length === 0) return
if (ids.includes(NaN)) return
const tracksRaw = await prisma.track.findMany({ where: { id: { in: ids } } })
const tracksRaw = db.findMany(Tables.Track, ids)
if (tracksRaw.length !== ids.length) {
return
@ -159,10 +168,8 @@ class Cache {
}
}
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 (isNaN(Number(params?.id))) return
const data = db.find(Tables.Album, params.id)
if (data?.json)
return {
resourceState: true,
@ -173,86 +180,91 @@ class Cache {
break
}
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 (isNaN(Number(params?.id))) return
const data = db.find(Tables.Playlist, params.id)
if (data?.json) return JSON.parse(data.json)
break
}
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
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 CacheAPIs.ArtistAlbum: {
const typedQuery = query as CacheAPIsParams[CacheAPIs.ArtistAlbum]
const id = Number(typedQuery?.id)
if (isNaN(id)) return
if (isNaN(Number(params?.id))) return
const artistAlbums = await prisma.artistAlbum.findUnique({ where: { id } })
if (!artistAlbums?.hotAlbums) return
const ids = artistAlbums.hotAlbums.split(',').map(Number)
const artistAlbumsRaw = db.find(Tables.ArtistAlbum, params.id)
if (!artistAlbumsRaw?.json) return
const artistAlbums = JSON.parse(artistAlbumsRaw.json)
const albumsRaw = await prisma.album.findMany({
where: { id: { in: ids } },
})
if (albumsRaw.length !== ids.length) return
const albumsRaw = db.findMany(Tables.Album, artistAlbums.hotAlbums)
if (albumsRaw.length !== artistAlbums.hotAlbums.length) return
const albums = albumsRaw.map(a => JSON.parse(a.json))
return {
hotAlbums: ids.map((id: number) => albums.find(a => a.id === id)),
}
artistAlbums.hotAlbums = artistAlbums.hotAlbums.map((id: number) =>
albums.find(a => a.id === id)
)
return artistAlbums
}
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 (isNaN(Number(params?.id))) return
const data = db.find(Tables.Lyrics, params.id)
if (data?.json) return JSON.parse(data.json)
break
}
case CacheAPIs.CoverColor: {
// if (isNaN(Number(params?.id))) return
// return db.find(Tables.CoverColor, params.id)?.color
if (isNaN(Number(params?.id))) return
return db.find(Tables.CoverColor, params.id)?.color
}
case CacheAPIs.Artist: {
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 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)
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 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)
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
}
}
return
}
async getAudio(filename: string, reply: FastifyReply) {
if (!filename) {
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) {
prisma.audio.delete({ where: { id } })
db.delete(Tables.Audio, id)
fs.unlinkSync(path)
return reply.status(404).send({ error: 'Audio not found' })
}
await prisma.audio.update({ where: { id }, data: { updatedAt: new Date() } })
db.update(Tables.Audio, id, { queriedAt: Date.now() })
reply
.status(206)
.header('Accept-Ranges', 'bytes')
@ -264,10 +276,7 @@ class Cache {
}
}
async setAudio(
buffer: Buffer,
{ id, url, bitrate }: { id: number; url: string; bitrate: number }
) {
async setAudio(buffer: Buffer, { id, url }: { id: number; url: string }) {
const path = `${app.getPath('userData')}/audio_cache`
try {
@ -277,8 +286,8 @@ class Cache {
}
const meta = await musicMetadata.parseBuffer(buffer)
const bitRate = ~~((meta.format.bitrate || bitrate || 0) / 1000)
const format =
const bitRate = meta?.format?.codec === 'OPUS' ? 165000 : meta.format.bitrate ?? 0
const type =
{
'MPEG 1 Layer 3': 'mp3',
'Ogg Vorbis': 'ogg',
@ -287,23 +296,25 @@ class Cache {
OPUS: 'opus',
}[meta.format.codec ?? ''] ?? 'unknown'
let source = 'unknown'
let source: TablesStructures[Tables.Audio]['source'] = 'unknown'
if (url.includes('googlevideo.com')) source = 'youtube'
if (url.includes('126.net')) source = 'netease'
fs.writeFile(`${path}/${id}-${bitRate}.${format}`, buffer, async error => {
fs.writeFile(`${path}/${id}-${bitRate}.${type}`, buffer, error => {
if (error) {
return log.error(`[cache] cacheAudio failed: ${error}`)
}
log.info(`Audio file ${id}-${bitRate}.${type} cached!`)
const row = { id, bitRate, format, source }
await prisma.audio.upsert({
where: { id },
create: row,
update: row,
db.upsert(Tables.Audio, {
id,
bitRate,
format: type as TablesStructures[Tables.Audio]['format'],
source,
queriedAt: Date.now(),
})
log.info(`Audio file ${id}-${bitRate}.${format} cached!`)
log.info(`[cache] cacheAudio ${id}-${bitRate}.${type}`)
})
}
}

243
packages/desktop/main/db.ts Normal file
View file

@ -0,0 +1,243 @@
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',
Lyrics = 'Lyrics',
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.Lyrics]: CommonTableStructure
[Tables.AccountData]: {
id: string
json: string
updatedAt: number
}
[Tables.Audio]: {
id: number
bitRate: number
format: '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)
}
update<T extends TableNames>(
table: T,
key: TablesStructures[T]['id'],
data: Partial<TablesStructures[T]>
) {
// TODO:
}
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

@ -12,7 +12,6 @@ import { createMenu } from './menu'
import { isDev, isWindows, isLinux, isMac, appName } from './env'
import store from './store'
import initAppServer from './appServer/appServer'
import { initDatabase } from './prisma'
class Main {
win: BrowserWindow | null = null
@ -35,7 +34,6 @@ class Main {
app.whenReady().then(async () => {
log.info('[index] App ready')
await initDatabase()
await initAppServer()
this.createWindow()
this.handleAppEvents()

View file

@ -12,7 +12,7 @@ import { Thumbar } from './windowsTaskbar'
import fastFolderSize from 'fast-folder-size'
import path from 'path'
import prettyBytes from 'pretty-bytes'
import prisma from './prisma'
import { db, Tables } from './db'
const on = <T extends keyof IpcChannelsParams>(
channel: T,
@ -204,7 +204,7 @@ function initOtherIpcMain() {
* 退
*/
handle(IpcChannels.Logout, async () => {
await prisma.accountData.deleteMany({})
await db.truncate(Tables.AccountData)
return true
})

View file

@ -1,102 +0,0 @@
import { app } from 'electron'
import path from 'path'
import { PrismaClient } from '../prisma/client'
import { isDev, isWindows } from './env'
import log from './log'
import { createFileIfNotExist, dirname, isFileExist } from './utils'
import fs from 'fs'
import { dialog } from 'electron'
export const dbPath = path.join(app.getPath('userData'), 'r3play.db')
export const dbUrl = 'file:' + (isWindows ? '' : '//') + dbPath
log.info('[prisma] dbUrl', dbUrl)
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
createFileIfNotExist(dbPath)
// @ts-expect-error
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)
dialog.showErrorBox('Failed to init prisma', String(e))
app.exit()
}
export const initDatabase = async () => {
try {
const initSQLFile = fs
.readFileSync(path.join(dirname, 'migrations/init.sql'), 'utf-8')
.toString()
const tables = initSQLFile.split(';')
await Promise.all(
tables.map(sql => {
if (!sql.trim()) return
return prisma.$executeRawUnsafe(sql.trim()).catch(() => {
log.error('[prisma] failed to execute init sql >>> ', sql.trim())
})
})
)
} catch (e) {
dialog.showErrorBox('Failed to init prisma database', String(e))
app.exit()
}
log.info('[prisma] database initialized')
}
export default prisma

View file

@ -1,76 +1,77 @@
CREATE TABLE IF NOT EXISTS "AccountData" (
"id" TEXT NOT NULL PRIMARY KEY,
"id" TEXT NOT NULL,
"json" TEXT NOT NULL,
"updatedAt" DATETIME NOT NULL
"updatedAt" DATETIME NOT NULL,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS "AppData" (
"id" TEXT NOT NULL PRIMARY KEY,
"value" TEXT NOT NULL
"id" TEXT NOT NULL,
"value" TEXT NOT NULL,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS "Track" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"id" INTEGER NOT NULL,
"json" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS "Album" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"id" INTEGER NOT NULL,
"json" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS "Artist" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"id" INTEGER NOT NULL,
"json" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS "ArtistAlbum" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"id" INTEGER NOT NULL,
"hotAlbums" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS "Playlist" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"id" INTEGER NOT NULL,
"json" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS "Audio" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"id" INTEGER NOT NULL,
"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,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS "Lyrics" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"id" INTEGER NOT NULL,
"json" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS "AppleMusicAlbum" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"id" INTEGER NOT NULL,
"json" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS "AppleMusicArtist" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"id" INTEGER NOT NULL,
"json" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);

View file

@ -6,7 +6,7 @@
"main": "./main/index.js",
"author": "*",
"scripts": {
"postinstall": "prisma generate",
"postinstall": "tsx scripts/build.sqlite3.ts",
"dev": "tsx scripts/build.main.ts --watch",
"build": "tsx scripts/build.main.ts",
"pack": "electron-builder build -c .electron-builder.config.js",
@ -14,9 +14,7 @@
"test:types": "tsc --noEmit --project ./tsconfig.json",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"prisma:generate": "prisma generate",
"prisma:db-push": "prisma db push"
"test:coverage": "vitest run --coverage"
},
"engines": {
"node": ">=16.0.0"
@ -26,11 +24,10 @@
"@fastify/http-proxy": "^8.4.0",
"@fastify/multipart": "^7.4.0",
"@fastify/static": "^6.6.1",
"@prisma/client": "^4.8.1",
"@prisma/engines": "^4.9.0",
"@sentry/electron": "^3.0.7",
"@yimura/scraper": "^1.2.4",
"NeteaseCloudMusicApi": "^4.8.9",
"better-sqlite3": "8.1.0",
"change-case": "^4.1.2",
"compare-versions": "^4.1.3",
"electron-log": "^4.4.8",
@ -39,22 +36,21 @@
"fastify": "^4.5.3",
"http-proxy-agent": "^5.0.0",
"pretty-bytes": "^6.0.0",
"prisma": "^4.8.1",
"ytdl-core": "^4.11.2"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.3",
"@vitest/ui": "^0.20.3",
"axios": "^1.2.1",
"cross-env": "^7.0.3",
"dotenv": "^16.0.3",
"electron": "^22.1.0",
"electron": "^23.1.1",
"electron-builder": "23.6.0",
"electron-devtools-installer": "^3.2.0",
"electron-rebuild": "^3.2.9",
"electron-releases": "^3.1171.0",
"esbuild": "^0.16.10",
"minimist": "^1.2.7",
"music-metadata": "^8.1.0",
"minimist": "^1.2.8",
"music-metadata": "^8.1.3",
"open-cli": "^7.1.0",
"ora": "^6.1.2",
"picocolors": "^1.0.0",

View file

@ -1,86 +0,0 @@
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
}
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

@ -35,6 +35,7 @@ const options = {
...builtinModules.filter(x => !/^_|^(internal|v8|node-inspect)\/|\//.test(x)),
'electron',
'NeteaseCloudMusicApi',
'better-sqlite3',
],
}

View file

@ -0,0 +1,163 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const { rebuild } = require('electron-rebuild')
const fs = require('fs')
const minimist = require('minimist')
const pc = require('picocolors')
const pkg = require(`${process.cwd()}/package.json`)
const axios = require('axios')
const { execSync } = require('child_process')
const { resolve } = require('path')
const { promisify } = require('util')
const stream = require('stream')
type Arch = typeof process.arch
const isWindows = process.platform === 'win32'
const isMac = process.platform === 'darwin'
const isLinux = process.platform === 'linux'
const argv = minimist(process.argv.slice(2))
const electronVersion = pkg.devDependencies.electron.replaceAll('^', '')
const betterSqlite3Version = pkg.dependencies['better-sqlite3'].replaceAll('^', '')
const projectDir = resolve(process.cwd(), '../../')
const tmpDir = resolve(projectDir, `./tmp/better-sqlite3`)
const binDir = resolve(projectDir, `./tmp/bin`)
console.log(pc.cyan(`projectDir=${projectDir}`))
console.log(pc.cyan(`binDir=${binDir}`))
const finished = promisify(stream.finished)
if (!fs.existsSync(binDir)) {
console.log(pc.cyan(`Creating dist/binary directory: ${binDir}`))
fs.mkdirSync(binDir, {
recursive: true,
})
}
// Get Electron Module Version
let electronModuleVersion = ''
async function getElectronModuleVersion() {
const releases = await axios.get('https://releases.electronjs.org/releases.json')
if (!releases.data) {
console.error(pc.red('Can not get electron releases'))
process.exit(1)
}
electronModuleVersion = releases.data.find(r => r.version.includes(electronVersion))?.modules
if (!electronModuleVersion) {
console.error(pc.red('Can not find electron module version in electron-releases'))
process.exit(1)
}
console.log(pc.cyan(`electronModuleVersion=${electronModuleVersion}`))
}
// Download better-sqlite library from GitHub Release
async function download(arch: Arch) {
console.log(pc.cyan(`Downloading ${arch} binary...`))
if (!electronModuleVersion) {
console.log(pc.red('No electron module version found! Skip download.'))
return false
}
const fileName = `better-sqlite3-v${betterSqlite3Version}-electron-v${electronModuleVersion}-${process.platform}-${arch}`
const zipFileName = `${fileName}.tar.gz`
const url = `https://github.com/JoshuaWise/better-sqlite3/releases/download/v${betterSqlite3Version}/${zipFileName}`
if (!fs.existsSync(tmpDir)) {
fs.mkdirSync(tmpDir, {
recursive: true,
})
}
try {
await axios({
method: 'get',
url,
responseType: 'stream',
}).then(response => {
const writer = fs.createWriteStream(resolve(tmpDir, `./${zipFileName}`))
response.data.pipe(writer)
return finished(writer)
})
} catch (e) {
console.log(pc.red('Download failed! Skip download.', e))
return false
}
try {
execSync(`tar -xvzf ${tmpDir}/${zipFileName} -C ${tmpDir}`)
} catch (e) {
console.log(pc.red('Extract failed! Skip extract.', e))
return false
}
try {
fs.copyFileSync(
resolve(tmpDir, './build/Release/better_sqlite3.node'),
resolve(binDir, `./better_sqlite3_${process.platform}_${arch}.node`)
)
} catch (e) {
console.log(pc.red('Copy failed! Skip copy.', e))
return false
}
try {
fs.rmSync(resolve(tmpDir, `./build`), { recursive: true, force: true })
} catch (e) {
console.log(pc.red('Delete failed! Skip delete.'))
return false
}
return true
}
// Build better-sqlite library on this device
async function build(arch: Arch) {
const downloaded = await download(arch)
if (downloaded) {
return
}
console.log(pc.cyan(`Building for ${arch}...`))
await rebuild({
projectRootPath: projectDir,
buildPath: process.cwd(),
electronVersion,
arch,
onlyModules: ['better-sqlite3'],
force: true,
})
.then(() => {
console.info('Build succeeded')
const from = resolve(
projectDir,
`./node_modules/better-sqlite3/build/Release/better_sqlite3.node`
)
const to = resolve(binDir, `./better_sqlite3_${process.platform}_${arch}.node`)
console.info(`copy ${from} to ${to}`)
fs.copyFileSync(from, to)
})
.catch(e => {
console.error(pc.red('Build failed!'))
console.error(pc.red(e))
})
}
async function main() {
await getElectronModuleVersion()
if (argv.x64 || argv.arm64 || argv.arm) {
if (argv.x64) await build('x64')
if (argv.arm64) await build('arm64')
} else {
if (isWindows) {
await build('x64')
} else if (isMac) {
await build('x64')
await build('arm64')
} else if (isLinux) {
await build('x64')
await build('arm64')
}
}
}
main()

View file

@ -0,0 +1,63 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const path = require('path')
const pc = require('picocolors')
const fs = require('fs')
const archs = ['ia32', 'x64', 'armv7l', 'arm64', 'universal']
const projectDir = path.resolve(process.cwd(), '../../')
const binDir = `${projectDir}/tmp/bin`
console.log(pc.cyan(`projectDir=${projectDir}`))
console.log(pc.cyan(`binDir=${binDir}`))
exports.default = async function (context) {
// console.log(context)
const platform = context.electronPlatformName
const arch = archs?.[context.arch]
// Mac
if (platform === 'darwin') {
if (arch === 'universal') return // Skip universal we already copy binary for x64 and arm64
if (arch !== 'x64' && arch !== 'arm64') return // Skip other archs
const from = `${binDir}/better_sqlite3_darwin_${arch}.node`
const to = `${context.appOutDir}/${context.packager.appInfo.productFilename}.app/Contents/Resources/bin/better_sqlite3.node`
console.info(`copy ${from} to ${to}`)
const toFolder = to.replace('/better_sqlite3.node', '')
if (!fs.existsSync(toFolder)) {
fs.mkdirSync(toFolder, {
recursive: true,
})
}
try {
fs.copyFileSync(from, to)
} catch (e) {
console.log(pc.red('Copy failed! Process stopped.'))
throw e
}
}
if (platform === 'win32') {
if (arch !== 'x64') return // Skip other archs
const from = `${binDir}/better_sqlite3_win32_${arch}.node`
const to = `${context.appOutDir}/resources/bin/better_sqlite3.node`
console.info(`copy ${from} to ${to}`)
const toFolder = to.replace('/better_sqlite3.node', '')
if (!fs.existsSync(toFolder)) {
fs.mkdirSync(toFolder, {
recursive: true,
})
}
try {
fs.copyFileSync(from, to)
} catch (e) {
console.log(pc.red('Copy failed! Process stopped.'))
throw e
}
}
}