feat: 缓存歌单封面颜色

This commit is contained in:
qier222 2022-04-12 01:48:14 +08:00
parent 766e866497
commit 1591586735
No known key found for this signature in database
GPG key ID: 9C85007ED905F14D
10 changed files with 506 additions and 396 deletions

15
src/main/CacheAPIsName.ts Normal file
View file

@ -0,0 +1,15 @@
export enum APIs {
UserPlaylist = 'user/playlist',
UserAccount = 'user/account',
Personalized = 'personalized',
RecommendResource = 'recommend/resource',
Likelist = 'likelist',
SongDetail = 'song/detail',
SongUrl = 'song/url',
Album = 'album',
PlaylistDetail = 'playlist/detail',
Artists = 'artists',
ArtistAlbum = 'artist/album',
Lyric = 'lyric',
CoverColor = 'cover_color',
}

View file

@ -6,4 +6,5 @@ export enum IpcChannels {
IsMaximized = 'is-maximized', IsMaximized = 'is-maximized',
GetApiCacheSync = 'get-api-cache-sync', GetApiCacheSync = 'get-api-cache-sync',
DevDbExportJson = 'dev-db-export-json', DevDbExportJson = 'dev-db-export-json',
CacheCoverColor = 'cache-cover-color',
} }

View file

@ -5,300 +5,322 @@ import { Request, Response } from 'express'
import logger from './logger' import logger from './logger'
import fs from 'fs' import fs from 'fs'
import * as musicMetadata from 'music-metadata' import * as musicMetadata from 'music-metadata'
import { APIs } from './CacheAPIsName'
export async function setCache(api: string, data: any, query: any) { class Cache {
switch (api) { constructor() {
case 'user/playlist': //
case 'user/account': }
case 'personalized':
case 'recommend/resource': set(api: string, data: any, query: any = {}) {
case 'likelist': { switch (api) {
if (!data) return case APIs.UserPlaylist:
db.upsert(Tables.ACCOUNT_DATA, { case APIs.UserAccount:
id: api, case APIs.Personalized:
json: JSON.stringify(data), case APIs.RecommendResource:
updateAt: Date.now(), case APIs.Likelist: {
}) if (!data) return
break db.upsert(Tables.AccountData, {
} id: api,
case 'song/detail': { json: JSON.stringify(data),
if (!data.songs) return updateAt: Date.now(),
const tracks = (data as FetchTracksResponse).songs.map(t => ({ })
id: t.id, break
json: JSON.stringify(t), }
updatedAt: Date.now(), case APIs.SongDetail: {
})) if (!data.songs) return
db.upsertMany(Tables.TRACK, tracks) const tracks = (data as FetchTracksResponse).songs.map(t => ({
break id: t.id,
} json: JSON.stringify(t),
case '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 'playlist/detail': {
if (!data.playlist) return
db.upsert(Tables.PLAYLIST, {
id: data.playlist.id,
json: JSON.stringify(data),
updatedAt: Date.now(),
})
break
}
case 'artists': {
if (!data.artist) return
db.upsert(Tables.ARTIST, {
id: data.artist.id,
json: JSON.stringify(data),
updatedAt: Date.now(),
})
break
}
case 'artist/album': {
if (!data.hotAlbums) return
db.createMany(
Tables.ALBUM,
data.hotAlbums.map((a: Album) => ({
id: a.id,
json: JSON.stringify(a),
updatedAt: Date.now(), updatedAt: Date.now(),
})) }))
) db.upsertMany(Tables.Track, tracks)
const modifiedData = { break
...data, }
hotAlbums: data.hotAlbums.map((a: Album) => a.id), 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.PlaylistDetail: {
if (!data.playlist) return
db.upsert(Tables.Playlist, {
id: data.playlist.id,
json: JSON.stringify(data),
updatedAt: Date.now(),
})
break
}
case APIs.Artists: {
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,
})
} }
db.upsert(Tables.ARTIST_ALBUMS, {
id: data.artist.id,
json: JSON.stringify(modifiedData),
updatedAt: Date.now(),
})
break
}
case 'lyric': {
if (!data.lrc) return
db.upsert(Tables.LYRIC, {
id: query.id,
json: JSON.stringify(data),
updatedAt: Date.now(),
})
break
} }
} }
}
export function getCache(api: string, query: any): any { get(api: string, query: any): any {
switch (api) { switch (api) {
case 'user/account': case APIs.UserPlaylist:
case 'user/playlist': case APIs.UserAccount:
case 'personalized': case APIs.Personalized:
case 'recommend/resource': case APIs.RecommendResource:
case 'likelist': { case APIs.Likelist: {
const data = db.find(Tables.ACCOUNT_DATA, api) const data = db.find(Tables.AccountData, api)
if (data?.json) return JSON.parse(data.json) if (data?.json) return JSON.parse(data.json)
break break
}
case 'song/detail': {
const ids: string[] = query?.ids.split(',')
if (ids.length === 0) return
let isIDsValid = true
ids.forEach(id => {
if (id === '' || isNaN(Number(id))) isIDsValid = false
})
if (!isIDsValid) return
const tracksRaw = db.findMany(Tables.TRACK, ids)
if (tracksRaw.length !== ids.length) {
return
} }
const tracks = ids.map(id => { case APIs.SongDetail: {
const track = tracksRaw.find(t => t.id === Number(id)) as any const ids: string[] = query?.ids.split(',')
return JSON.parse(track.json) if (ids.length === 0) return
})
let isIDsValid = true
ids.forEach(id => {
if (id === '' || isNaN(Number(id))) isIDsValid = false
})
if (!isIDsValid) 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(query?.id))) return
const data = db.find(Tables.Album, query.id)
if (data?.json)
return {
resourceState: true,
songs: [],
code: 200,
album: JSON.parse(data.json),
}
break
}
case APIs.PlaylistDetail: {
if (isNaN(Number(query?.id))) return
const data = db.find(Tables.Playlist, query.id)
if (data?.json) return JSON.parse(data.json)
break
}
case APIs.Artists: {
if (isNaN(Number(query?.id))) return
const data = db.find(Tables.Artist, query.id)
if (data?.json) return JSON.parse(data.json)
break
}
case APIs.ArtistAlbum: {
if (isNaN(Number(query?.id))) return
const artistAlbumsRaw = db.find(Tables.ArtistAlbum, query.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(query?.id))) return
const data = db.find(Tables.Lyric, query.id)
if (data?.json) return JSON.parse(data.json)
break
}
case APIs.CoverColor: {
if (isNaN(Number(query?.id))) return
return db.find(Tables.CoverColor, query.id)?.color
}
}
}
getForExpress(api: string, req: Request) {
// Get track detail cache
if (api === APIs.SongDetail) {
const cache = this.get(api, req.query)
if (cache) {
logger.debug(`[cache] Cache hit for ${req.path}`)
return cache
}
}
// Get audio cache if API is song/detail
if (api === APIs.SongUrl) {
const cache = db.find(Tables.Audio, Number(req.query.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
logger.debug(`[cache] Audio cache hit for ${req.path}`)
return { return {
data: [
{
source: cache.source,
id: cache.id,
url: `http://127.0.0.1:42710/yesplaymusic/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, code: 200,
songs: tracks,
privileges: {},
} }
} }
case 'album': {
if (isNaN(Number(query?.id))) return
const data = db.find(Tables.ALBUM, query.id)
if (data?.json)
return {
resourceState: true,
songs: [],
code: 200,
album: JSON.parse(data.json),
}
break
}
case 'playlist/detail': {
if (isNaN(Number(query?.id))) return
const data = db.find(Tables.PLAYLIST, query.id)
if (data?.json) return JSON.parse(data.json)
break
}
case 'artists': {
if (isNaN(Number(query?.id))) return
const data = db.find(Tables.ARTIST, query.id)
if (data?.json) return JSON.parse(data.json)
break
}
case 'artist/album': {
if (isNaN(Number(query?.id))) return
const artistAlbumsRaw = db.find(Tables.ARTIST_ALBUMS, query.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 'lyric': {
if (isNaN(Number(query?.id))) return
const data = db.find(Tables.LYRIC, query.id)
if (data?.json) return JSON.parse(data.json)
break
}
} }
}
export async function getCacheForExpress(api: string, req: Request) { getAudio(fileName: string, res: Response) {
// Get track detail cache if (!fileName) {
if (api === 'song/detail') { return res.status(400).send({ error: 'No filename provided' })
const cache = getCache(api, req.query) }
if (cache) { const id = Number(fileName.split('-')[0])
logger.debug(`[cache] Cache hit for ${req.path}`)
return cache 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.send(audio)
} catch (error) {
res.status(500).send({ error })
} }
} }
// Get audio cache if API is song/detail async setAudio(
if (api === 'song/url') { buffer: Buffer,
const cache = db.find(Tables.AUDIO, Number(req.query.id)) { id, source }: { id: number; source: string }
if (!cache) return ) {
const path = `${app.getPath('userData')}/audio_cache`
const audioFileName = `${cache.id}-${cache.br}.${cache.type}` try {
fs.statSync(path)
const isAudioFileExists = fs.existsSync( } catch (e) {
`${app.getPath('userData')}/audio_cache/${audioFileName}` fs.mkdirSync(path)
)
if (!isAudioFileExists) return
logger.debug(`[cache] Audio cache hit for ${req.path}`)
return {
data: [
{
source: cache.source,
id: cache.id,
url: `http://127.0.0.1:42710/yesplaymusic/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,
} }
}
}
export function getAudioCache(fileName: string, res: Response) { const meta = await musicMetadata.parseBuffer(buffer)
if (!fileName) { const br = meta.format.bitrate
return res.status(400).send({ error: 'No filename provided' }) const type = {
} 'MPEG 1 Layer 3': 'mp3',
const id = Number(fileName.split('-')[0]) 'Ogg Vorbis': 'ogg',
AAC: 'm4a',
FLAC: 'flac',
unknown: 'unknown',
}[meta.format.codec ?? 'unknown']
try { await fs.writeFile(`${path}/${id}-${br}.${type}`, buffer, error => {
const path = `${app.getPath('userData')}/audio_cache/${fileName}` if (error) {
const audio = fs.readFileSync(path) return logger.error(`[cache] cacheAudio failed: ${error}`)
if (audio.byteLength === 0) { }
db.delete(Tables.AUDIO, id) logger.info(`Audio file ${id}-${br}.${type} cached!`)
fs.unlinkSync(path)
return res.status(404).send({ error: 'Audio not found' })
}
res.send(audio)
} catch (error) {
res.status(500).send({ error })
}
}
// Cache audio info local folder db.upsert(Tables.Audio, {
export async function cacheAudio( id,
buffer: Buffer, br,
{ id, source }: { id: number; source: string } type,
) { source,
const path = `${app.getPath('userData')}/audio_cache` updateAt: Date.now(),
})
try { logger.info(`[cache] cacheAudio ${id}-${br}.${type}`)
fs.statSync(path)
} catch (e) {
fs.mkdirSync(path)
}
const meta = await musicMetadata.parseBuffer(buffer)
const br = meta.format.bitrate
const type = {
'MPEG 1 Layer 3': 'mp3',
'Ogg Vorbis': 'ogg',
AAC: 'm4a',
FLAC: 'flac',
unknown: 'unknown',
}[meta.format.codec ?? 'unknown']
await fs.writeFile(`${path}/${id}-${br}.${type}`, buffer, error => {
if (error) {
return logger.error(`[cache] cacheAudio failed: ${error}`)
}
logger.info(`Audio file ${id}-${br}.${type} cached!`)
db.upsert(Tables.AUDIO, {
id,
br,
type,
source,
updateAt: Date.now(),
}) })
}
logger.info(`[cache] cacheAudio ${id}-${br}.${type}`)
})
} }
export default new Cache()

View file

@ -7,110 +7,127 @@ import { createFileIfNotExist } from './utils'
const isDev = process.env.NODE_ENV === 'development' const isDev = process.env.NODE_ENV === 'development'
logger.info('[db] Initializing database...')
export enum Tables { export enum Tables {
TRACK = 'track', Track = 'track',
ALBUM = 'album', Album = 'album',
ARTIST = 'artist', Artist = 'artist',
PLAYLIST = 'playlist', Playlist = 'playlist',
ARTIST_ALBUMS = 'artist_album', ArtistAlbum = 'artist_album',
LYRIC = 'lyric', Lyric = 'lyric',
// Special tables // Special tables
ACCOUNT_DATA = 'account_data', AccountData = 'account_data',
AUDIO = 'audio', Audio = 'audio',
CoverColor = 'cover_color',
} }
const dbFilePath = path.resolve( class DB {
app.getPath('userData'), sqlite: SQLite3.Database
'./api_cache/db.sqlite' dbFilePath: string = path.resolve(
) app.getPath('userData'),
createFileIfNotExist(dbFilePath) './api_cache/db.sqlite'
const sqlite = new SQLite3(dbFilePath, {
nativeBinding: path.join(__dirname, `./better_sqlite3_${process.arch}.node`),
})
sqlite.pragma('auto_vacuum = FULL')
// Init tables if not exist
const trackTable = sqlite
.prepare("SELECT * FROM sqlite_master WHERE name='track' and type='table'")
.get()
if (!trackTable) {
const migration = fs.readFileSync(
isDev
? path.join(process.cwd(), './src/main/migrations/init.sql')
: path.join(__dirname, './migrations/init.sql'),
'utf8'
) )
sqlite.exec(migration)
}
export const db = { constructor() {
find: (table: Tables, key: number | string) => { logger.info('[db] Initializing database...')
return sqlite
createFileIfNotExist(this.dbFilePath)
this.sqlite = new SQLite3(this.dbFilePath, {
nativeBinding: path.join(
__dirname,
`./better_sqlite3_${process.arch}.node`
),
})
this.sqlite.pragma('auto_vacuum = FULL')
this.initTables()
logger.info('[db] Database initialized')
}
initTables() {
const migration = fs.readFileSync(
isDev
? path.join(process.cwd(), './src/main/migrations/init.sql')
: path.join(__dirname, './migrations/init.sql'),
'utf8'
)
this.sqlite.exec(migration)
}
find(table: Tables, key: number | string) {
return this.sqlite
.prepare(`SELECT * FROM ${table} WHERE id = ? LIMIT 1`) .prepare(`SELECT * FROM ${table} WHERE id = ? LIMIT 1`)
.get(key) .get(key)
}, }
findMany: (table: Tables, keys: number[] | string[]) => {
findMany(table: Tables, keys: number[] | string[]) {
const idsQuery = keys.map(key => `id = ${key}`).join(' OR ') const idsQuery = keys.map(key => `id = ${key}`).join(' OR ')
return sqlite.prepare(`SELECT * FROM ${table} WHERE ${idsQuery}`).all() return this.sqlite.prepare(`SELECT * FROM ${table} WHERE ${idsQuery}`).all()
}, }
findAll: (table: Tables) => {
return sqlite.prepare(`SELECT * FROM ${table}`).all() findAll(table: Tables) {
}, return this.sqlite.prepare(`SELECT * FROM ${table}`).all()
create: (table: Tables, data: any, skipWhenExist: boolean = true) => { }
create(table: Tables, data: any, skipWhenExist: boolean = true) {
if (skipWhenExist && db.find(table, data.id)) return if (skipWhenExist && db.find(table, data.id)) return
return sqlite.prepare(`INSERT INTO ${table} VALUES (?)`).run(data) return this.sqlite.prepare(`INSERT INTO ${table} VALUES (?)`).run(data)
}, }
createMany: (table: Tables, data: any[], skipWhenExist: boolean = true) => {
createMany(table: Tables, data: any[], skipWhenExist: boolean = true) {
const valuesQuery = Object.keys(data[0]) const valuesQuery = Object.keys(data[0])
.map(key => `:${key}`) .map(key => `:${key}`)
.join(', ') .join(', ')
const insert = sqlite.prepare( const insert = this.sqlite.prepare(
`INSERT ${ `INSERT ${
skipWhenExist ? 'OR IGNORE' : '' skipWhenExist ? 'OR IGNORE' : ''
} INTO ${table} VALUES (${valuesQuery})` } INTO ${table} VALUES (${valuesQuery})`
) )
const insertMany = sqlite.transaction((rows: any[]) => { const insertMany = this.sqlite.transaction((rows: any[]) => {
rows.forEach((row: any) => insert.run(row)) rows.forEach((row: any) => insert.run(row))
}) })
insertMany(data) insertMany(data)
}, }
upsert: (table: Tables, data: any) => {
upsert(table: Tables, data: any) {
const valuesQuery = Object.keys(data) const valuesQuery = Object.keys(data)
.map(key => `:${key}`) .map(key => `:${key}`)
.join(', ') .join(', ')
return sqlite return this.sqlite
.prepare(`INSERT OR REPLACE INTO ${table} VALUES (${valuesQuery})`) .prepare(`INSERT OR REPLACE INTO ${table} VALUES (${valuesQuery})`)
.run(data) .run(data)
}, }
upsertMany: (table: Tables, data: any[]) => {
upsertMany(table: Tables, data: any[]) {
const valuesQuery = Object.keys(data[0]) const valuesQuery = Object.keys(data[0])
.map(key => `:${key}`) .map(key => `:${key}`)
.join(', ') .join(', ')
const upsert = sqlite.prepare( const upsert = this.sqlite.prepare(
`INSERT OR REPLACE INTO ${table} VALUES (${valuesQuery})` `INSERT OR REPLACE INTO ${table} VALUES (${valuesQuery})`
) )
const upsertMany = sqlite.transaction((rows: any[]) => { const upsertMany = this.sqlite.transaction((rows: any[]) => {
rows.forEach((row: any) => upsert.run(row)) rows.forEach((row: any) => upsert.run(row))
}) })
upsertMany(data) upsertMany(data)
}, }
delete: (table: Tables, key: number | string) => {
return sqlite.prepare(`DELETE FROM ${table} WHERE id = ?`).run(key) delete(table: Tables, key: number | string) {
}, return this.sqlite.prepare(`DELETE FROM ${table} WHERE id = ?`).run(key)
deleteMany: (table: Tables, keys: number[] | string[]) => { }
deleteMany(table: Tables, keys: number[] | string[]) {
const idsQuery = keys.map(key => `id = ${key}`).join(' OR ') const idsQuery = keys.map(key => `id = ${key}`).join(' OR ')
return sqlite.prepare(`DELETE FROM ${table} WHERE ${idsQuery}`).run() return this.sqlite.prepare(`DELETE FROM ${table} WHERE ${idsQuery}`).run()
}, }
truncate: (table: Tables) => {
return sqlite.prepare(`DELETE FROM ${table}`).run() truncate(table: Tables) {
}, return this.sqlite.prepare(`DELETE FROM ${table}`).run()
vacuum: () => { }
return sqlite.prepare('VACUUM').run()
}, vacuum() {
return this.sqlite.prepare('VACUUM').run()
}
} }
logger.info('[db] Database initialized') export const db = new DB()

View file

@ -1,9 +1,10 @@
import { BrowserWindow, ipcMain, app } from 'electron' import { BrowserWindow, ipcMain, app } from 'electron'
import { db, Tables } from './db' import { db, Tables } from './db'
import { IpcChannels } from './IpcChannelsName' import { IpcChannels } from './IpcChannelsName'
import { getCache } from './cache' import cache from './cache'
import logger from './logger' import logger from './logger'
import fs from 'fs' import fs from 'fs'
import { APIs } from './CacheAPIsName'
/** /**
* win对象的事件 * win对象的事件
@ -28,13 +29,13 @@ export function initIpcMain(win: BrowserWindow | null) {
* API缓存 * API缓存
*/ */
ipcMain.on(IpcChannels.ClearAPICache, () => { ipcMain.on(IpcChannels.ClearAPICache, () => {
db.truncate(Tables.TRACK) db.truncate(Tables.Track)
db.truncate(Tables.ALBUM) db.truncate(Tables.Album)
db.truncate(Tables.ARTIST) db.truncate(Tables.Artist)
db.truncate(Tables.PLAYLIST) db.truncate(Tables.Playlist)
db.truncate(Tables.ARTIST_ALBUMS) db.truncate(Tables.ArtistAlbum)
db.truncate(Tables.ACCOUNT_DATA) db.truncate(Tables.AccountData)
db.truncate(Tables.AUDIO) db.truncate(Tables.Audio)
db.vacuum() db.vacuum()
}) })
@ -43,24 +44,32 @@ ipcMain.on(IpcChannels.ClearAPICache, () => {
*/ */
ipcMain.on(IpcChannels.GetApiCacheSync, (event, args) => { ipcMain.on(IpcChannels.GetApiCacheSync, (event, args) => {
const { api, query } = args const { api, query } = args
const data = getCache(api, query) const data = cache.get(api, query)
event.returnValue = data event.returnValue = data
}) })
/**
*
*/
ipcMain.on(IpcChannels.CacheCoverColor, (event, args) => {
const { id, color } = args.query
cache.set(APIs.CoverColor, { id, color })
})
/** /**
* tables到json文件便table大小dev环境 * tables到json文件便table大小dev环境
*/ */
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
ipcMain.on(IpcChannels.DevDbExportJson, () => { ipcMain.on(IpcChannels.DevDbExportJson, () => {
const tables = [ const tables = [
Tables.ARTIST_ALBUMS, Tables.ArtistAlbum,
Tables.PLAYLIST, Tables.Playlist,
Tables.ALBUM, Tables.Album,
Tables.TRACK, Tables.Track,
Tables.ARTIST, Tables.Artist,
Tables.AUDIO, Tables.Audio,
Tables.ACCOUNT_DATA, Tables.AccountData,
Tables.LYRIC, Tables.Lyric,
] ]
tables.forEach(table => { tables.forEach(table => {
const data = db.findAll(table) const data = db.findAll(table)

View file

@ -1,8 +1,9 @@
CREATE TABLE "account_data" ("id" text NOT NULL,"json" text NOT NULL,"updateAt" int NOT NULL, PRIMARY KEY (id)); CREATE TABLE IF NOT EXISTS "account_data" ("id" text NOT NULL,"json" text NOT NULL,"updateAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE "album" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id)); CREATE TABLE IF NOT EXISTS "album" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE "artist_album" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id)); CREATE TABLE IF NOT EXISTS "artist_album" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE "artist" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id)); CREATE TABLE IF NOT EXISTS "artist" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE "audio" ("id" integer NOT NULL,"br" int NOT NULL,"type" text NOT NULL,"srouce" text NOT NULL,"updateAt" int NOT NULL, PRIMARY KEY (id)); CREATE TABLE IF NOT EXISTS "audio" ("id" integer NOT NULL,"br" int NOT NULL,"type" text NOT NULL,"srouce" text NOT NULL,"updateAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE "lyric" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" integer NOT NULL, PRIMARY KEY (id)); CREATE TABLE IF NOT EXISTS "lyric" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" integer NOT NULL, PRIMARY KEY (id));
CREATE TABLE "playlist" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id)); CREATE TABLE IF NOT EXISTS "playlist" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE "track" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id)); CREATE TABLE IF NOT EXISTS "track" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "cover_color" ("id" integer NOT NULL,"color" text NOT NULL, PRIMARY KEY (id));

View file

@ -2,12 +2,7 @@ import { pathCase } from 'change-case'
import cookieParser from 'cookie-parser' import cookieParser from 'cookie-parser'
import express, { Request, Response } from 'express' import express, { Request, Response } from 'express'
import logger from './logger' import logger from './logger'
import { import cache from './cache'
setCache,
getCacheForExpress,
cacheAudio,
getAudioCache,
} from './cache'
import fileUpload from 'express-fileupload' import fileUpload from 'express-fileupload'
import path from 'path' import path from 'path'
@ -32,8 +27,8 @@ Object.entries(neteaseApi).forEach(([name, handler]) => {
logger.debug(`[server] Handling request: ${req.path}`) logger.debug(`[server] Handling request: ${req.path}`)
// Get from cache // Get from cache
const cache = await getCacheForExpress(name, req) const cacheData = await cache.getForExpress(name, req)
if (cache) return res.json(cache) if (cacheData) return res.json(cacheData)
// Request netease api // Request netease api
try { try {
@ -42,7 +37,7 @@ Object.entries(neteaseApi).forEach(([name, handler]) => {
cookie: req.cookies, cookie: req.cookies,
}) })
setCache(name, result.body, req.query) cache.set(name, result.body, req.query)
return res.send(result.body) return res.send(result.body)
} catch (error) { } catch (error) {
return res.status(500).send(error) return res.status(500).send(error)
@ -57,7 +52,7 @@ Object.entries(neteaseApi).forEach(([name, handler]) => {
app.get( app.get(
'/yesplaymusic/audio/:filename', '/yesplaymusic/audio/:filename',
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
getAudioCache(req.params.filename, res) cache.getAudio(req.params.filename, res)
} }
) )
app.post('/yesplaymusic/audio/:id', async (req: Request, res: Response) => { app.post('/yesplaymusic/audio/:id', async (req: Request, res: Response) => {
@ -78,7 +73,7 @@ app.post('/yesplaymusic/audio/:id', async (req: Request, res: Response) => {
} }
try { try {
await cacheAudio(req.files.file.data, { await cache.setAudio(req.files.file.data, {
id: id, id: id,
source: 'netease', source: 'netease',
}) })

View file

@ -2,14 +2,23 @@
// We suggest you to commit this file into source control // We suggest you to commit this file into source control
declare global { declare global {
const classNames: typeof import('classnames')['default'] const classNames: typeof import('classnames')['default']
const createRef: typeof import('react')['createRef']
const forwardRef: typeof import('react')['forwardRef']
const lazy: typeof import('react')['lazy']
const memo: typeof import('react')['memo']
const startTransition: typeof import('react')['startTransition']
const toast: typeof import('react-hot-toast')['toast'] const toast: typeof import('react-hot-toast')['toast']
const useCallback: typeof import('react')['useCallback'] const useCallback: typeof import('react')['useCallback']
const useContext: typeof import('react')['useContext'] const useContext: typeof import('react')['useContext']
const useDebugValue: typeof import('react')['useDebugValue'] const useDebugValue: typeof import('react')['useDebugValue']
const useDeferredValue: typeof import('react')['useDeferredValue']
const useEffect: typeof import('react')['useEffect'] const useEffect: typeof import('react')['useEffect']
const useEffectOnce: typeof import('react-use')['useEffectOnce'] const useEffectOnce: typeof import('react-use')['useEffectOnce']
const useId: typeof import('react')['useId']
const useImperativeHandle: typeof import('react')['useImperativeHandle'] const useImperativeHandle: typeof import('react')['useImperativeHandle']
const useInfiniteQuery: typeof import('react-query')['useInfiniteQuery'] const useInfiniteQuery: typeof import('react-query')['useInfiniteQuery']
const useInsertionEffect: typeof import('react')['useInsertionEffect']
const useLayoutEffect: typeof import('react')['useLayoutEffect']
const useMemo: typeof import('react')['useMemo'] const useMemo: typeof import('react')['useMemo']
const useMutation: typeof import('react-query')['useMutation'] const useMutation: typeof import('react-query')['useMutation']
const useNavigate: typeof import('react-router-dom')['useNavigate'] const useNavigate: typeof import('react-router-dom')['useNavigate']
@ -19,5 +28,7 @@ declare global {
const useRef: typeof import('react')['useRef'] const useRef: typeof import('react')['useRef']
const useSnapshot: typeof import('valtio')['useSnapshot'] const useSnapshot: typeof import('valtio')['useSnapshot']
const useState: typeof import('react')['useState'] const useState: typeof import('react')['useState']
const useSyncExternalStore: typeof import('react')['useSyncExternalStore']
const useTransition: typeof import('react')['useTransition']
} }
export {} export {}

View file

@ -1,7 +1,7 @@
import { average } from 'color.js' import { average } from 'color.js'
import { colord } from 'colord' import { colord } from 'colord'
import { player } from '@/renderer/store' import { player } from '@/renderer/store'
import { resizeImage } from '@/renderer/utils/common' import { resizeImage, getCoverColor } from '@/renderer/utils/common'
import SvgIcon from './SvgIcon' import SvgIcon from './SvgIcon'
import ArtistInline from './ArtistsInline' import ArtistInline from './ArtistsInline'
import { import {
@ -59,7 +59,7 @@ const MediaControls = () => {
const FMCard = () => { const FMCard = () => {
const navigate = useNavigate() const navigate = useNavigate()
const [background, setBackground] = useState('') const [bgColor, setBgColor] = useState({ from: '#222', to: '#222' })
const playerSnapshot = useSnapshot(player) const playerSnapshot = useSnapshot(player)
const track = useMemo(() => playerSnapshot.fmTrack, [playerSnapshot.fmTrack]) const track = useMemo(() => playerSnapshot.fmTrack, [playerSnapshot.fmTrack])
@ -69,24 +69,19 @@ const FMCard = () => {
) )
useEffect(() => { useEffect(() => {
const cover = resizeImage(track?.al?.picUrl ?? '', 'xs') getCoverColor(track?.al?.picUrl || '').then(color => {
if (cover) { if (!color) return
average(cover, { amount: 1, format: 'hex', sample: 1 }).then(color => { const to = colord(color).darken(0.15).rotate(-5).toHex()
let c = colord(color as string) setBgColor({ from: color, to })
const hsl = c.toHsl() })
if (hsl.s > 50) c = colord({ ...hsl, s: 50 })
if (hsl.l > 50) c = colord({ ...c.toHsl(), l: 50 })
if (hsl.l < 30) c = colord({ ...c.toHsl(), l: 30 })
const to = c.darken(0.15).rotate(-5).toHex()
setBackground(`linear-gradient(to bottom, ${c.toHex()}, ${to})`)
})
}
}, [track?.al?.picUrl]) }, [track?.al?.picUrl])
return ( return (
<div <div
className='relative flex h-[198px] overflow-hidden rounded-2xl bg-gray-100 p-4 dark:bg-gray-800' className='relative flex h-[198px] overflow-hidden rounded-2xl bg-gray-100 p-4 dark:bg-gray-800'
style={{ background }} style={{
background: `linear-gradient(to bottom, ${bgColor.from}, ${bgColor.to})`,
}}
> >
{coverUrl ? ( {coverUrl ? (
<img <img

View file

@ -1,5 +1,9 @@
import { IpcChannels } from '@/main/IpcChannelsName'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration' import duration from 'dayjs/plugin/duration'
import { APIs } from '@/main/CacheAPIsName'
import { average } from 'color.js'
import { colord } from 'colord'
/** /**
* @description * @description
@ -70,10 +74,10 @@ export function formatDuration(
dayjs.extend(duration) dayjs.extend(duration)
let time = dayjs.duration(milliseconds) const time = dayjs.duration(milliseconds)
let hours = time.hours().toString() const hours = time.hours().toString()
let mins = time.minutes().toString() const mins = time.minutes().toString()
let seconds = time.seconds().toString().padStart(2, '0') const seconds = time.seconds().toString().padStart(2, '0')
if (format === 'hh:mm:ss') { if (format === 'hh:mm:ss') {
return hours !== '0' return hours !== '0'
@ -111,3 +115,43 @@ export function scrollToTop(smooth = false) {
if (!main) return if (!main) return
main.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' }) main.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' })
} }
export async function getCoverColor(coverUrl: string) {
const id = new URL(coverUrl).pathname.split('/').pop()?.split('.')[0]
const colorFromCache = window.ipcRenderer?.sendSync(
IpcChannels.GetApiCacheSync,
{
api: APIs.CoverColor,
query: {
id,
},
}
) as string | undefined
return colorFromCache || calcCoverColor(coverUrl)
}
export async function cacheCoverColor(coverUrl: string, color: string) {
const id = new URL(coverUrl).pathname.split('/').pop()?.split('.')[0]
window.ipcRenderer?.send(IpcChannels.CacheCoverColor, {
api: APIs.CoverColor,
query: {
id,
color,
},
})
}
export async function calcCoverColor(coverUrl: string) {
if (!coverUrl) return
const cover = resizeImage(coverUrl, 'xs')
return average(cover, { amount: 1, format: 'hex', sample: 1 }).then(color => {
let c = colord(color as string)
const hsl = c.toHsl()
if (hsl.s > 50) c = colord({ ...hsl, s: 50 })
if (hsl.l > 50) c = colord({ ...c.toHsl(), l: 50 })
if (hsl.l < 30) c = colord({ ...c.toHsl(), l: 30 })
cacheCoverColor(coverUrl, c.toHex())
return c.toHex()
})
}