feat: 支持 UNM rust

This commit is contained in:
qier222 2022-05-01 19:53:25 +08:00
parent 4d59401549
commit 4d54060a4f
No known key found for this signature in database
GPG Key ID: 9C85007ED905F14D
29 changed files with 717 additions and 231 deletions

View File

@ -41,6 +41,7 @@
"dependencies": {
"@sentry/node": "^6.19.7",
"@sentry/tracing": "^6.19.7",
"@unblockneteasemusic/rust-napi": "^0.3.0-pre.1",
"NeteaseCloudMusicApi": "^4.5.12",
"better-sqlite3": "7.5.1",
"change-case": "^4.1.2",

View File

@ -22,6 +22,7 @@ specifiers:
'@types/react-dom': ^18.0.1
'@typescript-eslint/eslint-plugin': ^5.21.0
'@typescript-eslint/parser': ^5.21.0
'@unblockneteasemusic/rust-napi': ^0.3.0-pre.1
'@vitejs/plugin-react': ^1.3.1
'@vitest/ui': ^0.10.0
NeteaseCloudMusicApi: ^4.5.12
@ -88,6 +89,7 @@ specifiers:
dependencies:
'@sentry/node': 6.19.7
'@sentry/tracing': 6.19.7
'@unblockneteasemusic/rust-napi': 0.3.0-pre.1
NeteaseCloudMusicApi: 4.5.12
better-sqlite3: 7.5.1
change-case: 4.1.2
@ -1212,6 +1214,142 @@ packages:
eslint-visitor-keys: 3.3.0
dev: true
/@unblockneteasemusic/rust-napi-android-arm-eabi/0.3.0-pre.1:
resolution: {integrity: sha512-932T6uUSHbWXTS2lt0wTI5F2lsIrGea2aU22VwSFaHfpTgxB8qDfd+jn+zMRlpnqTmuglyd0hk/1yUZd9tgu+w==}
engines: {node: '>= 10'}
cpu: [arm]
os: [android]
requiresBuild: true
dev: false
optional: true
/@unblockneteasemusic/rust-napi-android-arm64/0.3.0-pre.1:
resolution: {integrity: sha512-RQMCzO7+0Iw+R/MHy0hvv9Vg6BzqrUmWk9bMLR0mkkYKxR0wPEaB7WpAvUfLRKevSqiWP8rrNRuzqGVBu0PaCg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
requiresBuild: true
dev: false
optional: true
/@unblockneteasemusic/rust-napi-darwin-arm64/0.3.0-pre.1:
resolution: {integrity: sha512-M3YvPhYNyBSytho3FmyX1cj5k21ZlW14mPuy/5oLRw4qehAmjsSYjCEFLG5I29IlZTLN0sbIz92dqHkYclSXSg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: false
optional: true
/@unblockneteasemusic/rust-napi-darwin-x64/0.3.0-pre.1:
resolution: {integrity: sha512-kN4Bur22hFo2UAJ4vljuEX4ue7TlhhOnz9Q3KrwhxOtv2KlQi2iQ/8tCl+/whKpqgf/cs/klQLDJj73PsE1G+w==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: false
optional: true
/@unblockneteasemusic/rust-napi-freebsd-x64/0.3.0-pre.1:
resolution: {integrity: sha512-tRBudpZX+0X8sDSP+LmnU9nfsT7939rCu+bZhizjHHe2jt02iX/ZLHOkEcVBh0VHhHVvTehj0zH3iHFkfYnR2Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [freebsd]
requiresBuild: true
dev: false
optional: true
/@unblockneteasemusic/rust-napi-linux-arm-gnueabihf/0.3.0-pre.1:
resolution: {integrity: sha512-3vkXlBm6f2dWOWLKaosTcFAO5b/VV9WvyT3PQBJFvq0PtRGonr2Zr1gYJC4zUz2UraSKaFg4GMKgopU2Duxgow==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@unblockneteasemusic/rust-napi-linux-arm64-gnu/0.3.0-pre.1:
resolution: {integrity: sha512-Zq1kjjXhle0OA7NzadzBQvjbTZfbK/qMuHay97+ZGXZH4uxv0jmJ2aQWR7HlrrKmQKpknURvrxbXmi8dxeI+SA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@unblockneteasemusic/rust-napi-linux-arm64-musl/0.3.0-pre.1:
resolution: {integrity: sha512-Yp7+Ra8ksx2nCZs18duK7BPtsY2chzdrBIrWY14N7aP0IIglwBcazP+GGFNaqqDx0nW+/0463pUsi8OgbWX+mA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@unblockneteasemusic/rust-napi-linux-x64-gnu/0.3.0-pre.1:
resolution: {integrity: sha512-A71/PhBCotAQPimGIJnZEYJwBv2FilhYC1OC4wOy3Rt54C9Cw12FJp49c7J13mZLktZfCJOSu6/6RPY8+6Yfrw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@unblockneteasemusic/rust-napi-linux-x64-musl/0.3.0-pre.1:
resolution: {integrity: sha512-klXwwdVb4LdHmUrdclZSfn6nQwXddBwJJk392wRagjGUyNbUkC9b3JHfMEdrssMIPtIGtNHWt/43z+saovZl2g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@unblockneteasemusic/rust-napi-win32-arm64-msvc/0.3.0-pre.1:
resolution: {integrity: sha512-jaX6UvQlRuH1iyextG34l8b19MtVFTZZX8U34oW3d7rZxcas5ZitEHzd6XfjpHcTtkXSyhQxx+WjDiY2BQ+B3A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
requiresBuild: true
dev: false
optional: true
/@unblockneteasemusic/rust-napi-win32-ia32-msvc/0.3.0-pre.1:
resolution: {integrity: sha512-QgO05vQxxkU0+bfprxQMVLXxguI8N1ApPQCyAYNnvrKTQ/F6OjV+bQghlWaKwcIeve8zoYN1zgSHDyNj+0xrYA==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
requiresBuild: true
dev: false
optional: true
/@unblockneteasemusic/rust-napi-win32-x64-msvc/0.3.0-pre.1:
resolution: {integrity: sha512-6CI0YlQxHiU6vetwoAjYgBOFlWoTkLVUSd0tpEN9/5R7iExRUHdFdRfpXqPJzpYnAhZlGqAIslCayoNcf7vnQw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
requiresBuild: true
dev: false
optional: true
/@unblockneteasemusic/rust-napi/0.3.0-pre.1:
resolution: {integrity: sha512-n1zDJvy5OEEMPQdhTPARRRQLM4Tnvx9UGq0smVKWu6CjutK6rcSIVoxe4ADILzBOY3RCe5vuo9Qn4RUzKCeeWQ==}
engines: {node: '>= 10'}
optionalDependencies:
'@unblockneteasemusic/rust-napi-android-arm-eabi': 0.3.0-pre.1
'@unblockneteasemusic/rust-napi-android-arm64': 0.3.0-pre.1
'@unblockneteasemusic/rust-napi-darwin-arm64': 0.3.0-pre.1
'@unblockneteasemusic/rust-napi-darwin-x64': 0.3.0-pre.1
'@unblockneteasemusic/rust-napi-freebsd-x64': 0.3.0-pre.1
'@unblockneteasemusic/rust-napi-linux-arm-gnueabihf': 0.3.0-pre.1
'@unblockneteasemusic/rust-napi-linux-arm64-gnu': 0.3.0-pre.1
'@unblockneteasemusic/rust-napi-linux-arm64-musl': 0.3.0-pre.1
'@unblockneteasemusic/rust-napi-linux-x64-gnu': 0.3.0-pre.1
'@unblockneteasemusic/rust-napi-linux-x64-musl': 0.3.0-pre.1
'@unblockneteasemusic/rust-napi-win32-arm64-msvc': 0.3.0-pre.1
'@unblockneteasemusic/rust-napi-win32-ia32-msvc': 0.3.0-pre.1
'@unblockneteasemusic/rust-napi-win32-x64-msvc': 0.3.0-pre.1
dev: false
/@vitejs/plugin-react/1.3.1:
resolution: {integrity: sha512-qQS8Y2fZCjo5YmDUplEXl3yn+aueiwxB7BaoQ4nWYJYR+Ai8NXPVLlkLobVMs5+DeyFyg9Lrz6zCzdX1opcvyw==}
engines: {node: '>=12.0.0'}

View File

@ -2,7 +2,7 @@
const { colord } = require('colord')
const colors = require('tailwindcss/colors')
const replaceBrandColorWithCssVar = () => {
const replaceBrandColorWithCSSVar = () => {
const blues = Object.entries(colors.blue).map(([key, value]) => {
const c = colord(value).toRgb()
return {
@ -11,7 +11,7 @@ const replaceBrandColorWithCssVar = () => {
}
})
return {
postcssPlugin: 'replaceBrandColorWithCssVar',
postcssPlugin: 'replaceBrandColorWithCSSVar',
Declaration(decl) {
let value = decl.value
blues.forEach(blue => {
@ -33,12 +33,12 @@ const replaceBrandColorWithCssVar = () => {
},
}
}
replaceBrandColorWithCssVar.postcss = true
replaceBrandColorWithCSSVar.postcss = true
module.exports = {
plugins: [
require('tailwindcss'),
require('autoprefixer'),
replaceBrandColorWithCssVar,
replaceBrandColorWithCSSVar,
],
}

View File

@ -37,6 +37,7 @@ const options = {
'electron',
'NeteaseCloudMusicApi',
'better-sqlite3',
'@unblockneteasemusic/rust-napi'
],
}

View File

@ -1,30 +1,9 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const colors = require('tailwindcss/colors')
const { colord } = require('colord')
const prettier = require('prettier')
const fs = require('fs')
const prettierConfig = require('../prettier.config.js')
const pickedColors = {
blue: colors.blue,
red: colors.red,
orange: colors.orange,
amber: colors.amber,
yellow: colors.yellow,
lime: colors.lime,
green: colors.green,
emerald: colors.emerald,
teal: colors.teal,
cyan: colors.cyan,
sky: colors.sky,
indigo: colors.indigo,
violet: colors.violet,
purple: colors.purple,
fuchsia: colors.fuchsia,
pink: colors.pink,
rose: colors.rose,
}
module.exports = pickedColors
const pickedColors = require('./pickedColors.js')
const colorsCss = {}
Object.entries(pickedColors).forEach(([name, colors]) => {
@ -47,4 +26,3 @@ ${name === 'blue' ? ':root' : `[data-accent-color='${name}']`} {${color}
const formatted = prettier.format(css, { ...prettierConfig, parser: 'css' })
fs.writeFileSync('./src/renderer/styles/accentColor.scss', formatted)

24
scripts/pickedColors.js Normal file
View File

@ -0,0 +1,24 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const colors = require('tailwindcss/colors')
const pickedColors = {
blue: colors.blue,
red: colors.red,
orange: colors.orange,
amber: colors.amber,
yellow: colors.yellow,
lime: colors.lime,
green: colors.green,
emerald: colors.emerald,
teal: colors.teal,
cyan: colors.cyan,
sky: colors.sky,
indigo: colors.indigo,
violet: colors.violet,
purple: colors.purple,
fuchsia: colors.fuchsia,
pink: colors.pink,
rose: colors.rose,
}
module.exports = pickedColors

View File

@ -6,6 +6,7 @@ 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() {
@ -206,59 +207,6 @@ class Cache {
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
log.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,
}
}
}
getAudio(fileName: string, res: Response) {
@ -279,17 +227,17 @@ class Cache {
.status(206)
.setHeader('Accept-Ranges', 'bytes')
.setHeader('Connection', 'keep-alive')
.setHeader('Content-Range', `bytes 0-${audio.byteLength - 1}/${audio.byteLength}`)
.setHeader(
'Content-Range',
`bytes 0-${audio.byteLength - 1}/${audio.byteLength}`
)
.send(audio)
} catch (error) {
res.status(500).send({ error })
}
}
async setAudio(
buffer: Buffer,
{ id, source }: { id: number; source: string }
) {
async setAudio(buffer: Buffer, { id, url }: { id: number; url: string }) {
const path = `${app.getPath('userData')}/audio_cache`
try {
@ -299,16 +247,26 @@ class Cache {
}
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']
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'
await fs.writeFile(`${path}/${id}-${br}.${type}`, buffer, error => {
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}`)
}
@ -317,9 +275,9 @@ class Cache {
db.upsert(Tables.Audio, {
id,
br,
type,
type: type as TablesStructures[Tables.Audio]['type'],
source,
updateAt: Date.now(),
updatedAt: Date.now(),
})
log.info(`[cache] cacheAudio ${id}-${br}.${type}`)

View File

@ -38,9 +38,17 @@ export interface TablesStructures {
[Tables.Audio]: {
id: number
br: number
type: 'mp3' | 'flac' | 'ogg' | 'wav' | 'm4a' | 'aac' | 'unknown'
source: 'netease' | 'migu' | 'kuwo' | 'kugou' | 'youtube'
url: string
type: 'mp3' | 'flac' | 'ogg' | 'wav' | 'm4a' | 'aac' | 'unknown' | 'opus'
source:
| 'unknown'
| 'netease'
| 'migu'
| 'kuwo'
| 'kugou'
| 'youtube'
| 'qq'
| 'bilibili'
| 'joox'
updatedAt: number
}
[Tables.CoverColor]: {

View File

@ -15,19 +15,21 @@ import { initIpcMain } from './ipcMain'
import { createTray, YPMTray } from './tray'
import { IpcChannels } from '@/shared/IpcChannels'
import { createTaskbar, Thumbar } from './windowsTaskbar'
import { Store as State, initialState } from '@/shared/store'
const isWindows = process.platform === 'win32'
const isMac = process.platform === 'darwin'
const isLinux = process.platform === 'linux'
const isDev = process.env.NODE_ENV === 'development'
interface TypedElectronStore {
export interface TypedElectronStore {
window: {
width: number
height: number
x?: number
y?: number
}
settings: State['settings']
}
class Main {
@ -40,6 +42,7 @@ class Main {
width: 1440,
height: 960,
},
settings: initialState.settings,
},
})
@ -65,7 +68,7 @@ class Main {
this.handleWindowEvents()
this.createTray()
this.createThumbar()
initIpcMain(this.win, this.tray, this.thumbar)
initIpcMain(this.win, this.tray, this.thumbar, this.store)
this.initDevTools()
})
}
@ -129,6 +132,51 @@ class Main {
if (url.startsWith('https:')) shell.openExternal(url)
return { action: 'deny' }
})
this.disableCORS()
}
disableCORS() {
if (!this.win) return
function UpsertKeyValue(obj, keyToChange, value) {
const keyToChangeLower = keyToChange.toLowerCase()
for (const key of Object.keys(obj)) {
if (key.toLowerCase() === keyToChangeLower) {
// Reassign old key
obj[key] = value
// Done
return
}
}
// Insert at end instead
obj[keyToChange] = value
}
this.win.webContents.session.webRequest.onBeforeSendHeaders(
(details, callback) => {
const { requestHeaders, url } = details
UpsertKeyValue(requestHeaders, 'Access-Control-Allow-Origin', ['*'])
if (url.includes('googlevideo.com')) {
requestHeaders['Sec-Fetch-Mode'] = 'no-cors'
requestHeaders['Sec-Fetch-Dest'] = 'audio'
requestHeaders['Range'] = 'bytes=0-'
}
callback({ requestHeaders })
}
)
this.win.webContents.session.webRequest.onHeadersReceived(
(details, callback) => {
const { responseHeaders } = details
UpsertKeyValue(responseHeaders, 'Access-Control-Allow-Origin', ['*'])
UpsertKeyValue(responseHeaders, 'Access-Control-Allow-Headers', ['*'])
callback({
responseHeaders,
})
}
)
}
handleWindowEvents() {

View File

@ -4,6 +4,8 @@ import { IpcChannels, IpcChannelsParams } from '../shared/IpcChannels'
import cache from './cache'
import log from './log'
import fs from 'fs'
import Store from 'electron-store'
import { TypedElectronStore } from './index'
import { APIs } from '../shared/CacheAPIs'
import { YPMTray } from './tray'
import { Thumbar } from './windowsTaskbar'
@ -18,11 +20,14 @@ const on = <T extends keyof IpcChannelsParams>(
export function initIpcMain(
win: BrowserWindow | null,
tray: YPMTray | null,
thumbar: Thumbar | null
thumbar: Thumbar | null,
store: Store<TypedElectronStore>
) {
initWindowIpcMain(win)
initTrayIpcMain(tray)
initTaskbarIpcMain(thumbar)
initStoreIpcMain(store)
initOtherIpcMain()
}
/**
@ -51,9 +56,7 @@ function initWindowIpcMain(win: BrowserWindow | null) {
function initTrayIpcMain(tray: YPMTray | null) {
on(IpcChannels.SetTrayTooltip, (e, { text }) => tray?.setTooltip(text))
on(IpcChannels.Like, (e, { isLiked }) =>
tray?.setLikeState(isLiked)
)
on(IpcChannels.Like, (e, { isLiked }) => tray?.setLikeState(isLiked))
on(IpcChannels.Play, () => tray?.setPlayState(true))
on(IpcChannels.Pause, () => tray?.setPlayState(false))
@ -71,60 +74,82 @@ function initTaskbarIpcMain(thumbar: Thumbar | null) {
}
/**
* API缓存
* electron-store的事件
* @param {Store<TypedElectronStore>} store
*/
on(IpcChannels.ClearAPICache, () => {
db.truncate(Tables.Track)
db.truncate(Tables.Album)
db.truncate(Tables.Artist)
db.truncate(Tables.Playlist)
db.truncate(Tables.ArtistAlbum)
db.truncate(Tables.AccountData)
db.truncate(Tables.Audio)
db.vacuum()
})
/**
* Get API cache
*/
on(IpcChannels.GetApiCacheSync, (event, args) => {
const { api, query } = args
const data = cache.get(api, query)
event.returnValue = data
})
/**
*
*/
on(IpcChannels.CacheCoverColor, (event, args) => {
const { id, color } = args
cache.set(APIs.CoverColor, { id, color })
})
/**
* tables到json文件便table大小dev环境
*/
if (process.env.NODE_ENV === 'development') {
on(IpcChannels.DevDbExportJson, () => {
const tables = [
Tables.ArtistAlbum,
Tables.Playlist,
Tables.Album,
Tables.Track,
Tables.Artist,
Tables.Audio,
Tables.AccountData,
Tables.Lyric,
]
tables.forEach(table => {
const data = db.findAll(table)
fs.writeFile(`./tmp/${table}.json`, JSON.stringify(data), function (err) {
if (err) {
return console.log(err)
}
console.log('The file was saved!')
})
})
function initStoreIpcMain(store: Store<TypedElectronStore>) {
/**
* Main
*/
on(IpcChannels.SyncSettings, (event, settings) => {
store.set('settings', settings)
})
}
/**
*
*/
function initOtherIpcMain() {
/**
* API缓存
*/
on(IpcChannels.ClearAPICache, () => {
db.truncate(Tables.Track)
db.truncate(Tables.Album)
db.truncate(Tables.Artist)
db.truncate(Tables.Playlist)
db.truncate(Tables.ArtistAlbum)
db.truncate(Tables.AccountData)
db.truncate(Tables.Audio)
db.vacuum()
})
/**
* Get API cache
*/
on(IpcChannels.GetApiCacheSync, (event, args) => {
const { api, query } = args
const data = cache.get(api, query)
event.returnValue = data
})
/**
*
*/
on(IpcChannels.CacheCoverColor, (event, args) => {
const { id, color } = args
cache.set(APIs.CoverColor, { id, color })
})
/**
* tables到json文件便table大小dev环境
*/
if (process.env.NODE_ENV === 'development') {
on(IpcChannels.DevDbExportJson, () => {
const tables = [
Tables.ArtistAlbum,
Tables.Playlist,
Tables.Album,
Tables.Track,
Tables.Artist,
Tables.Audio,
Tables.AccountData,
Tables.Lyric,
]
tables.forEach(table => {
const data = db.findAll(table)
fs.writeFile(
`./tmp/${table}.json`,
JSON.stringify(data),
function (err) {
if (err) {
return console.log(err)
}
console.log('The file was saved!')
}
)
})
})
}
}

View File

@ -2,7 +2,7 @@ CREATE TABLE IF NOT EXISTS "AccountData" ("id" text NOT NULL,"json" text NOT NUL
CREATE TABLE IF NOT EXISTS "Album" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "ArtistAlbum" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "Artist" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "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,"updatedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "Lyric" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" integer NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "Playlist" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "Track" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));

View File

@ -5,6 +5,12 @@ 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 UNM from '@unblockneteasemusic/rust-napi'
import { APIs as CacheAPIs } from '../shared/CacheAPIs'
const isDev = process.env.NODE_ENV === 'development'
const isProd = process.env.NODE_ENV === 'production'
@ -21,6 +27,7 @@ class Server {
log.info('[server] starting http server')
this.app.use(cookieParser())
this.app.use(fileUpload())
this.getAudioUrlHandler()
this.neteaseHandler()
this.cacheAudioHandler()
this.serveStaticForProd()
@ -32,7 +39,9 @@ class Server {
const neteaseApi = require('NeteaseCloudMusicApi') as (params: any) => any[]
Object.entries(neteaseApi).forEach(([name, handler]) => {
if (['serveNcmApi', 'getModulesDefinitions'].includes(name)) return
if (['serveNcmApi', 'getModulesDefinitions', 'song_url'].includes(name)) {
return
}
name = pathCase(name)
@ -71,6 +80,205 @@ class Server {
}
}
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/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,
}
}
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) {
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) {
// 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] getFromNetease 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(
'/yesplaymusic/audio/:filename',
@ -103,8 +311,8 @@ class Server {
try {
await cache.setAudio(req.files.file.data, {
id: id,
source: 'netease',
id,
url: String(req.query.url) || '',
})
res.status(200).send('Audio cached!')
} catch (error) {

View File

@ -67,9 +67,7 @@ class ThumbarImpl implements Thumbar {
private _updateThumbarButtons(clear: boolean) {
this._win.setThumbarButtons(
clear
? []
: [this._previous, this._playOrPause, this._next]
clear ? [] : [this._previous, this._playOrPause, this._next]
)
}

View File

@ -12,7 +12,7 @@ const request: AxiosInstance = axios.create({
export async function cacheAudio(id: number, audio: string) {
const file = await axios.get(audio, { responseType: 'arraybuffer' })
if (file.status !== 200) return
if (file.status !== 200 && file.status !== 206) return
const formData = new FormData()
const blob = new Blob([file.data], { type: 'multipart/form-data' })

View File

@ -12,7 +12,6 @@ import { lyricParser } from '@/renderer/utils/lyric'
const Lyric = ({ className }: { className?: string }) => {
// const ease = [0.5, 0.2, 0.2, 0.8]
console.log('rendering')
const playerSnapshot = useSnapshot(player)
const track = useMemo(() => playerSnapshot.track, [playerSnapshot.track])

View File

@ -9,13 +9,8 @@ import { player } from '@/renderer/store'
import { formatDuration } from '@/renderer/utils/common'
import { State as PlayerState } from '@/renderer/utils/player'
const enableRenderLog = true
const PlayOrPauseButtonInTrack = memo(
({ isHighlight, trackID }: { isHighlight: boolean; trackID: number }) => {
if (enableRenderLog)
console.debug(`Rendering TracksAlbum.tsx PlayOrPauseButtonInTrack`)
const playerSnapshot = useSnapshot(player)
const isPlaying = useMemo(
() => playerSnapshot.state === PlayerState.Playing,
@ -58,9 +53,6 @@ const Track = memo(
isHighlight?: boolean
onClick: (e: React.MouseEvent<HTMLElement>, trackID: number) => void
}) => {
if (enableRenderLog)
console.debug(`Rendering TracksAlbum.tsx Track ${track.name}`)
const subtitle = useMemo(
() => track.tns?.at(0) ?? track.alia?.at(0),
[track.alia, track.tns]

View File

@ -179,8 +179,6 @@ const TracksList = memo(
isSkeleton?: boolean
onTrackDoubleClick?: (trackID: number) => void
}) => {
console.debug('Rendering TrackList.tsx TrackList')
// Fake data when isLoading is true
const skeletonTracks: Track[] = new Array(12).fill({})

View File

@ -1,8 +1,7 @@
import { IpcChannelsParams, IpcChannelsReturns } from '@/shared/IpcChannels'
import { useEffect } from 'react'
const useIpcRenderer = <T extends keyof IpcChannelsParams> (
const useIpcRenderer = <T extends keyof IpcChannelsParams>(
channcel: T,
listener: (event: any, value: IpcChannelsReturns[T]) => void
) => {

View File

@ -1,5 +1,9 @@
import { player } from '@/renderer/store'
import { IpcChannels, IpcChannelsReturns, IpcChannelsParams } from '@/shared/IpcChannels'
import {
IpcChannels,
IpcChannelsReturns,
IpcChannelsParams,
} from '@/shared/IpcChannels'
const on = <T extends keyof IpcChannelsParams>(
channel: T,

View File

@ -86,7 +86,9 @@ const Header = ({
return formatDuration(duration, 'zh-CN', 'hh[hr] mm[min]')
}, [album?.songs])
const [isCoverError, setCoverError] = useState(coverUrl.includes('3132508627578625'))
const [isCoverError, setCoverError] = useState(
coverUrl.includes('3132508627578625')
)
const { data: userAlbums } = useUserAlbums()
const isThisAlbumLiked = useMemo(() => {
@ -136,7 +138,7 @@ const Header = ({
coverUrl && (
<img
src={coverUrl}
className='rounded-2xl border w-full border-b-0 border-black border-opacity-5 dark:border-white dark:border-opacity-5'
className='w-full rounded-2xl border border-b-0 border-black border-opacity-5 dark:border-white dark:border-opacity-5'
onError={() => setCoverError(true)}
/>
)

View File

@ -18,8 +18,6 @@ import {
State as PlayerState,
} from '@/renderer/utils/player'
const enableRenderLog = true
const PlayButton = ({
playlist,
handlePlay,
@ -76,7 +74,6 @@ const Header = memo(
isLoading: boolean
handlePlay: () => void
}) => {
if (enableRenderLog) console.debug('Rendering Playlist.tsx Header')
const coverUrl = resizeImage(playlist?.coverImgUrl || '', 'lg')
const mutationLikeAPlaylist = useMutationLikeAPlaylist()
@ -225,8 +222,6 @@ const Tracks = memo(
handlePlay: (trackID: number | null) => void
isLoadingPlaylist: boolean
}) => {
if (enableRenderLog) console.debug('Rendering Playlist.tsx Tracks')
const {
data: tracksPages,
hasNextPage,
@ -281,8 +276,6 @@ const Tracks = memo(
Tracks.displayName = 'Tracks'
const Playlist = () => {
if (enableRenderLog) console.debug('Rendering Playlist.tsx Playlist')
const params = useParams()
const { data: playlist, isLoading } = usePlaylist({
id: Number(params.id) || 0,

View File

@ -35,10 +35,15 @@ const AccentColor = () => {
{Object.entries(colors).map(([color, bg]) => (
<div
key={color}
className={classNames(bg, 'mr-2.5 h-5 w-5 rounded-full flex items-center justify-center')}
className={classNames(
bg,
'mr-2.5 flex h-5 w-5 items-center justify-center rounded-full'
)}
onClick={() => changeColor(color)}
>
{color === accentColor && <div className='bg-white h-1.5 w-1.5 rounded-full'></div>}
{color === accentColor && (
<div className='h-1.5 w-1.5 rounded-full bg-white'></div>
)}
</div>
))}
</div>
@ -47,12 +52,12 @@ const AccentColor = () => {
}
const Theme = () => {
return <div className='mt-4'>
<div className='mb-2 dark:text-white'></div>
<div>
return (
<div className='mt-4'>
<div className='mb-2 dark:text-white'></div>
<div></div>
</div>
</div>
)
}
const Appearance = () => {

View File

@ -2,6 +2,7 @@ import Avatar from '@/renderer/components/Avatar'
import SvgIcon from '@/renderer/components/SvgIcon'
import useUser from '@/renderer/hooks/useUser'
import Appearance from './Appearance'
import UnblockNeteaseMusic from './UnblockNeteaseMusic'
const UserCard = () => {
const { data: user } = useUser()
@ -42,17 +43,30 @@ const UserCard = () => {
)
}
const Sidebar = () => {
const categories = ['外观', '播放', '歌词', '其他', '试验性功能']
const active = '外观'
const Sidebar = ({
activeCategory,
setActiveCategory,
}: {
activeCategory: string
setActiveCategory: (category: string) => void
}) => {
const categories = [
'外观',
'播放',
'歌词',
'其他',
'UnblockNeteaseMusic',
'试验性功能',
]
return (
<div>
{categories.map(category => (
<div
key={category}
onClick={() => setActiveCategory(category)}
className={classNames(
'btn-hover-animation my-px flex cursor-default items-center justify-between rounded-lg px-3 py-2 font-medium transition-colors duration-200 after:scale-[0.97] after:bg-black/[.06] dark:text-white dark:after:bg-white/10',
active === category
activeCategory === category
? 'text-black after:scale-100 after:opacity-100'
: 'text-gray-600'
)}
@ -65,14 +79,20 @@ const Sidebar = () => {
}
const Settings = () => {
const [activeCategory, setActiveCategory] = useState('外观')
return (
<div className='mt-6'>
<UserCard />
<div className='mt-8 grid grid-cols-[12rem_auto] gap-10'>
<Sidebar />
<Sidebar
activeCategory={activeCategory}
setActiveCategory={setActiveCategory}
/>
<div className=''>
<Appearance />
{activeCategory === '外观' && <Appearance />}
{activeCategory === 'UnblockNeteaseMusic' && <UnblockNeteaseMusic />}
</div>
</div>
</div>

View File

@ -0,0 +1,44 @@
const UnblockNeteaseMusic = () => {
return (
<div>
<div className='text-xl font-medium text-gray-800 dark:text-white/70'>
UnblockNeteaseMusic
</div>
<div className='mt-3 h-px w-full bg-black/5 dark:bg-white/10'></div>
<div>
:
<div>
<input type='checkbox' id='migu' value='migu' />
<label htmlFor='migu'>migu</label>
</div>
<div>
<input type='checkbox' id='youtube' value='youtube' />
<label htmlFor='youtube'>youtube</label>
</div>
<div>
<input type='checkbox' id='kugou' value='kugou' />
<label htmlFor='kugou'>kugou</label>
</div>
<div>
<input type='checkbox' id='kuwo' value='kuwo' />
<label htmlFor='kuwo'>kuwo</label>
</div>
<div>
<input type='checkbox' id='qq' value='qq' />
<label htmlFor='qq'>qq</label>
</div>
<div>
<input type='checkbox' id='bilibili' value='bilibili' />
<label htmlFor='bilibili'>bilibili</label>
</div>
<div>
<input type='checkbox' id='joox' value='joox' />
<label htmlFor='joox'>joox</label>
</div>
</div>
</div>
)
}
export default UnblockNeteaseMusic

View File

@ -1,37 +1,27 @@
import { proxy, subscribe } from 'valtio'
import { devtools } from 'valtio/utils'
import { Player } from '@/renderer/utils/player'
import {merge} from 'lodash-es'
interface Store {
uiStates: {
loginPhoneCountryCode: string
showLyricPanel: boolean
}
settings: {
showSidebar: boolean
accentColor: string
}
}
const initialState: Store = {
uiStates: {
loginPhoneCountryCode: '+86',
showLyricPanel: false,
},
settings: {
showSidebar: true,
accentColor: 'blue',
},
}
import { merge } from 'lodash-es'
import { IpcChannels } from '@/shared/IpcChannels'
import { Store, initialState } from '@/shared/store'
const stateInLocalStorage = localStorage.getItem('state')
export const state = proxy<Store>(
merge(initialState, stateInLocalStorage ? JSON.parse(stateInLocalStorage) : {})
merge(initialState, [
stateInLocalStorage ? JSON.parse(stateInLocalStorage) : {},
{
uiStates: {
showLyricPanel: false,
},
},
])
)
subscribe(state, () => {
localStorage.setItem('state', JSON.stringify(state))
})
subscribe(state.settings, () => {
window.ipcRenderer?.send(IpcChannels.SyncSettings, { ...state.settings })
})
// player
const playerInLocalStorage = localStorage.getItem('player')

View File

@ -224,15 +224,19 @@ export class Player {
}
if (this.trackID !== id) return
Howler.unload()
const url = audio.includes('?')
? `${audio}&ypm-id=${id}`
: `${audio}?ypm-id=${id}`
const howler = new Howl({
src: [`${audio}?id=${id}`],
format: ['mp3', 'flac'],
src: [url],
format: ['mp3', 'flac', 'webm'],
html5: true,
autoplay,
volume: 1,
onend: () => this._howlerOnEndCallback(),
})
_howler = howler
window.howler = howler
if (autoplay) {
this.play()
this.state = State.Playing
@ -257,7 +261,7 @@ export class Player {
private _cacheAudio(audio: string) {
if (audio.includes('yesplaymusic')) return
const id = Number(audio.split('?id=')[1])
const id = Number(new URL(audio).searchParams.get('ypm-id'))
if (isNaN(id) || !id) return
cacheAudio(id, audio)
}

View File

@ -1,5 +1,6 @@
import { APIs } from './CacheAPIs'
import { RepeatMode } from './playerDataTypes'
import { Store } from '@/shared/store'
export const enum IpcChannels {
ClearAPICache = 'clear-api-cache',
@ -19,6 +20,7 @@ export const enum IpcChannels {
Previous = 'previous',
Like = 'like',
Repeat = 'repeat',
SyncSettings = 'sync-settings',
}
// ipcMain.on params
@ -51,6 +53,7 @@ export interface IpcChannelsParams {
[IpcChannels.Repeat]: {
mode: RepeatMode
}
[IpcChannels.SyncSettings]: Store['settings']
}
// ipcRenderer.on params

46
src/shared/store.ts Normal file
View File

@ -0,0 +1,46 @@
export interface Store {
uiStates: {
loginPhoneCountryCode: string
showLyricPanel: boolean
}
settings: {
showSidebar: boolean
accentColor: string
unm: {
enabled: boolean
sources: Array<
'migu' | 'kuwo' | 'kugou' | 'ytdl' | 'qq' | 'bilibili' | 'joox'
>
searchMode: 'order-first' | 'fast-first'
proxy: null | {
protocol: 'http' | 'https' | 'socks5'
host: string
port: number
username?: string
password?: string
}
cookies: {
qq?: string
joox?: string
}
}
}
}
export const initialState: Store = {
uiStates: {
loginPhoneCountryCode: '+86',
showLyricPanel: false,
},
settings: {
showSidebar: true,
accentColor: 'blue',
unm: {
enabled: true,
sources: ['migu'],
searchMode: 'order-first',
proxy: null,
cookies: {},
},
},
}

View File

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const colors = require('tailwindcss/colors')
const pickedColors = require('./scripts/generate.accent.color.css.js')
const pickedColors = require('./scripts/pickedColors.js')
module.exports = {
content: [