mirror of
https://github.com/GiriNeko/YesPlayMusic.git
synced 2025-12-17 13:48:02 +00:00
feat: updates
This commit is contained in:
parent
47e41dea9b
commit
ebebf2a733
160 changed files with 4148 additions and 2001 deletions
283
packages/desktop/main/airplay.ts
Normal file
283
packages/desktop/main/airplay.ts
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import { $ } from 'zx'
|
||||
import store from './store'
|
||||
import { ipcMain, BrowserWindow } from 'electron'
|
||||
import { getNetworkInfo, sleep } from './utils'
|
||||
import { spawn, ChildProcessWithoutNullStreams } from 'child_process'
|
||||
import log from './log'
|
||||
|
||||
type Protocol = 'dmap' | 'mrp' | 'airplay' | 'companion' | 'raop'
|
||||
interface Device {
|
||||
name: string
|
||||
address: string
|
||||
identifier: string
|
||||
services: { protocol: Protocol; port: number }[]
|
||||
}
|
||||
|
||||
class Airplay {
|
||||
devices: Device[] = []
|
||||
pairProcess: ChildProcessWithoutNullStreams | null = null
|
||||
window: BrowserWindow
|
||||
|
||||
constructor(window: BrowserWindow) {
|
||||
log.debug('[airplay] ini')
|
||||
this.window = window
|
||||
this.initIpc()
|
||||
}
|
||||
|
||||
async checkIsInstalled() {
|
||||
const help = (await $`atvscript -h`).toString()
|
||||
return String(help).includes('usage: atvscript')
|
||||
}
|
||||
|
||||
async scanDevices(excludeThisDevice: boolean = true) {
|
||||
if (!this.checkIsInstalled()) {
|
||||
return {
|
||||
result: 'failure',
|
||||
error: 'pyatv is not installed',
|
||||
}
|
||||
}
|
||||
|
||||
let scanResult = null
|
||||
try {
|
||||
scanResult = await $`atvscript scan`
|
||||
} catch (p: any) {
|
||||
return {
|
||||
result: 'failure',
|
||||
error: p.stderr,
|
||||
}
|
||||
}
|
||||
|
||||
let json = null
|
||||
try {
|
||||
json = JSON.parse(scanResult.toString())
|
||||
} catch (e) {
|
||||
return {
|
||||
result: 'failure',
|
||||
error: String(e),
|
||||
}
|
||||
}
|
||||
|
||||
if (excludeThisDevice) {
|
||||
const macAddress = getNetworkInfo()?.mac
|
||||
if (macAddress) {
|
||||
json.devices = json.devices.filter(
|
||||
(device: Device) =>
|
||||
device.identifier.toLowerCase() !== macAddress.toLowerCase()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (json.result === 'success') {
|
||||
this.devices = json.devices
|
||||
}
|
||||
|
||||
return json
|
||||
}
|
||||
|
||||
async pairDevice(deviceID: string, protocol: Protocol) {
|
||||
this.pairProcess = spawn('atvremote', [
|
||||
'--id',
|
||||
deviceID,
|
||||
'--protocol',
|
||||
protocol,
|
||||
'pair',
|
||||
])
|
||||
|
||||
let paired = false
|
||||
let done = false
|
||||
this.pairProcess.stdout.on('data', (data: any) => {
|
||||
console.log('stdout', String(data))
|
||||
if (data.includes('You may now use these credentials:')) {
|
||||
store.set(
|
||||
`airplay.credentials.${deviceID}`,
|
||||
String(data).split('credentials:')[1].trim()
|
||||
)
|
||||
paired = true
|
||||
done = true
|
||||
}
|
||||
if (data.includes('Pairing failed')) {
|
||||
paired = false
|
||||
done = true
|
||||
}
|
||||
})
|
||||
|
||||
while (!done) {
|
||||
console.log('not done yet')
|
||||
await sleep(1000)
|
||||
}
|
||||
|
||||
return paired
|
||||
}
|
||||
|
||||
async enterPairPin(pin: string) {
|
||||
if (!this.pairProcess) {
|
||||
return false
|
||||
}
|
||||
this.pairProcess.stdin.write(`${pin}\n`)
|
||||
return true
|
||||
}
|
||||
|
||||
async playUrl(deviceID: string, url: string) {
|
||||
log.debug(`[airplay] playUrl ${url}`)
|
||||
const credentials: string = store.get(`airplay.credentials.${deviceID}`)
|
||||
if (url.includes('127.0.0.1')) {
|
||||
const ip = getNetworkInfo()?.address
|
||||
if (ip) url = url.replace('127.0.0.1', ip)
|
||||
}
|
||||
|
||||
try {
|
||||
spawn('atvremote', [
|
||||
'--id',
|
||||
deviceID,
|
||||
'--airplay-credentials',
|
||||
credentials,
|
||||
`play_url=${url}`,
|
||||
])
|
||||
} catch (p: any) {
|
||||
return {
|
||||
result: 'failure',
|
||||
error: p.stderr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getPlaying(deviceID: string) {
|
||||
if (!this.checkIsInstalled()) {
|
||||
return {
|
||||
result: 'failure',
|
||||
error: 'pyatv is not installed',
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = store.get(`airplay.credentials.${deviceID}`)
|
||||
|
||||
let playing = null
|
||||
try {
|
||||
playing =
|
||||
await $`atvscript --id ${deviceID} --airplay-credentials=${credentials} playing`
|
||||
} catch (p: any) {
|
||||
return {
|
||||
result: 'failure',
|
||||
error: p.stderr,
|
||||
}
|
||||
}
|
||||
|
||||
let json = null
|
||||
try {
|
||||
json = JSON.parse(playing.toString())
|
||||
} catch (e) {
|
||||
return {
|
||||
result: 'failure',
|
||||
error: String(e),
|
||||
}
|
||||
}
|
||||
|
||||
return json
|
||||
}
|
||||
|
||||
async play(deviceID: string) {
|
||||
const credentials = store.get(`airplay.credentials.${deviceID}`)
|
||||
try {
|
||||
$`atvscript --id ${deviceID} --airplay-credentials ${credentials} play`
|
||||
} catch (p: any) {
|
||||
return {
|
||||
result: 'failure',
|
||||
error: p.stderr,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
result: 'success',
|
||||
}
|
||||
}
|
||||
|
||||
async pause(deviceID: string) {
|
||||
const credentials = store.get(`airplay.credentials.${deviceID}`)
|
||||
try {
|
||||
$`atvscript --id ${deviceID} --airplay-credentials ${credentials} pause`
|
||||
} catch (p: any) {
|
||||
return {
|
||||
result: 'failure',
|
||||
error: p.stderr,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
result: 'success',
|
||||
}
|
||||
}
|
||||
|
||||
async playOrPause(deviceID: string) {
|
||||
const credentials = store.get(`airplay.credentials.${deviceID}`)
|
||||
try {
|
||||
$`atvscript --id ${deviceID} --airplay-credentials ${credentials} play_pause`
|
||||
} catch (p: any) {
|
||||
return {
|
||||
result: 'failure',
|
||||
error: p.stderr,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
result: 'success',
|
||||
}
|
||||
}
|
||||
|
||||
async setProgress(deviceID: string, progress: number) {
|
||||
const credentials = store.get(`airplay.credentials.${deviceID}`)
|
||||
try {
|
||||
$`atvremote --id ${deviceID} --airplay-credentials ${credentials} set_position=${progress}`
|
||||
} catch (p: any) {
|
||||
return {
|
||||
result: 'failure',
|
||||
error: p.stderr,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
result: 'success',
|
||||
}
|
||||
}
|
||||
|
||||
async pushUpdates(deviceID: string) {
|
||||
const credentials = store.get(`airplay.credentials.${deviceID}`)
|
||||
let updates = null
|
||||
try {
|
||||
updates = $`atvscript --id ${deviceID} --airplay-credentials ${credentials} push_updates`
|
||||
} catch (p: any) {
|
||||
return {
|
||||
result: 'failure',
|
||||
error: p.stderr,
|
||||
}
|
||||
}
|
||||
|
||||
for await (const chunk of updates.stdout) {
|
||||
this.window.webContents.send('airplay-updates', chunk)
|
||||
}
|
||||
}
|
||||
|
||||
async initIpc() {
|
||||
ipcMain.handle('airplay-scan-devices', () => {
|
||||
return this.scanDevices()
|
||||
})
|
||||
|
||||
ipcMain.handle('airplay-pair', async () => {
|
||||
console.log('airplay-pair')
|
||||
return this.pairDevice('58:D3:49:F0:C9:71', 'airplay')
|
||||
})
|
||||
|
||||
ipcMain.handle('airplay-pair-enter-pin', async (e, pin) => {
|
||||
return this.enterPairPin(pin)
|
||||
})
|
||||
|
||||
ipcMain.handle('airplay-play-url', async (e, { deviceID, url }) => {
|
||||
return this.playUrl(deviceID, url)
|
||||
})
|
||||
|
||||
ipcMain.handle('airplay-get-playing', async (e, { deviceID }) => {
|
||||
return this.getPlaying(deviceID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default Airplay
|
||||
87
packages/desktop/main/appleMusic.ts
Normal file
87
packages/desktop/main/appleMusic.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import log from './log'
|
||||
import axios from 'axios'
|
||||
import { AppleMusicAlbum, AppleMusicArtist } from '@/shared/AppleMusic'
|
||||
|
||||
const headers = {
|
||||
Authority: 'amp-api.music.apple.com',
|
||||
Accept: '*/*',
|
||||
Authorization:
|
||||
'Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IldlYlBsYXlLaWQifQ.eyJpc3MiOiJBTVBXZWJQbGF5IiwiaWF0IjoxNjQ2NjU1MDgwLCJleHAiOjE2NjIyMDcwODB9.pyOrt2FmP0cHkzYtO8KiEzQL2t1qpRszzxIYbLH7faXSddo6PQei771Ja3aGwGVU4hD99lZAw7bwat60iBcGiQ',
|
||||
Referer: 'https://music.apple.com/',
|
||||
'Sec-Fetch-Dest': 'empty',
|
||||
'Sec-Fetch-Mode': 'cors',
|
||||
'Sec-Fetch-Site': 'cross-site',
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Cider/1.5.1 Chrome/100.0.4896.160 Electron/18.3.3 Safari/537.36',
|
||||
'Accept-Encoding': 'gzip',
|
||||
}
|
||||
|
||||
export const getAlbum = async ({
|
||||
name,
|
||||
artist,
|
||||
}: {
|
||||
name: string
|
||||
artist: string
|
||||
}): Promise<AppleMusicAlbum | undefined> => {
|
||||
const keyword = `${artist} ${name}`
|
||||
log.debug(`[appleMusic] getAlbum: ${keyword}`)
|
||||
const searchResult = await axios({
|
||||
method: 'GET',
|
||||
headers,
|
||||
url: 'https://amp-api.music.apple.com/v1/catalog/us/search',
|
||||
params: {
|
||||
term: keyword,
|
||||
types: 'albums',
|
||||
'fields[albums]': 'artistName,name,editorialVideo,editorialNotes',
|
||||
platform: 'web',
|
||||
limit: '5',
|
||||
l: 'en-us', // TODO: get from settings
|
||||
},
|
||||
}).catch(e => {
|
||||
log.debug('[appleMusic] Search album error', e)
|
||||
})
|
||||
|
||||
const albums: AppleMusicAlbum[] | undefined =
|
||||
searchResult?.data?.results?.albums?.data
|
||||
const album =
|
||||
albums?.find(
|
||||
a =>
|
||||
a.attributes.name.toLowerCase() === name.toLowerCase() &&
|
||||
a.attributes.artistName.toLowerCase() === artist.toLowerCase()
|
||||
) || albums?.[0]
|
||||
if (!album) {
|
||||
log.debug('[appleMusic] No album found on apple music')
|
||||
return
|
||||
}
|
||||
|
||||
return album
|
||||
}
|
||||
|
||||
export const getArtist = async (
|
||||
name: string
|
||||
): Promise<AppleMusicArtist | undefined> => {
|
||||
const searchResult = await axios({
|
||||
method: 'GET',
|
||||
url: 'https://amp-api.music.apple.com/v1/catalog/us/search',
|
||||
headers,
|
||||
params: {
|
||||
term: name,
|
||||
types: 'artists',
|
||||
'fields[artists]': 'url,name,artwork,editorialVideo,artistBio',
|
||||
'omit[resource:artists]': 'relationships',
|
||||
platform: 'web',
|
||||
limit: '1',
|
||||
l: 'en-us', // TODO: get from settings
|
||||
},
|
||||
}).catch(e => {
|
||||
log.debug('[appleMusic] Search artist error', e)
|
||||
})
|
||||
|
||||
const artist = searchResult?.data?.results?.artists?.data?.[0]
|
||||
if (
|
||||
artist &&
|
||||
artist?.attributes?.name?.toLowerCase() === name.toLowerCase()
|
||||
) {
|
||||
return artist
|
||||
}
|
||||
}
|
||||
342
packages/desktop/main/cache.ts
Normal file
342
packages/desktop/main/cache.ts
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
import { db, Tables } from './db'
|
||||
import type { FetchTracksResponse } from '@/shared/api/Track'
|
||||
import { app } from 'electron'
|
||||
import { Request, Response } from 'express'
|
||||
import log from './log'
|
||||
import fs from 'fs'
|
||||
import * as musicMetadata from 'music-metadata'
|
||||
import { APIs, APIsParams, APIsResponse } from '@/shared/CacheAPIs'
|
||||
import { TablesStructures } from './db'
|
||||
|
||||
class Cache {
|
||||
constructor() {
|
||||
//
|
||||
}
|
||||
|
||||
async set(api: string, data: any, query: any = {}) {
|
||||
switch (api) {
|
||||
case APIs.UserPlaylist:
|
||||
case APIs.UserAccount:
|
||||
case APIs.Personalized:
|
||||
case APIs.RecommendResource:
|
||||
case APIs.UserAlbums:
|
||||
case APIs.UserArtists:
|
||||
case APIs.ListenedRecords:
|
||||
case APIs.Likelist: {
|
||||
if (!data) return
|
||||
db.upsert(Tables.AccountData, {
|
||||
id: api,
|
||||
json: JSON.stringify(data),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
break
|
||||
}
|
||||
case APIs.Track: {
|
||||
const res = data as FetchTracksResponse
|
||||
if (!res.songs) return
|
||||
const tracks = res.songs.map(t => ({
|
||||
id: t.id,
|
||||
json: JSON.stringify(t),
|
||||
updatedAt: Date.now(),
|
||||
}))
|
||||
db.upsertMany(Tables.Track, tracks)
|
||||
break
|
||||
}
|
||||
case APIs.Album: {
|
||||
if (!data.album) return
|
||||
data.album.songs = data.songs
|
||||
db.upsert(Tables.Album, {
|
||||
id: data.album.id,
|
||||
json: JSON.stringify(data.album),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
break
|
||||
}
|
||||
case APIs.Playlist: {
|
||||
if (!data.playlist) return
|
||||
db.upsert(Tables.Playlist, {
|
||||
id: data.playlist.id,
|
||||
json: JSON.stringify(data),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
break
|
||||
}
|
||||
case APIs.Artist: {
|
||||
if (!data.artist) return
|
||||
db.upsert(Tables.Artist, {
|
||||
id: data.artist.id,
|
||||
json: JSON.stringify(data),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
break
|
||||
}
|
||||
case APIs.ArtistAlbum: {
|
||||
if (!data.hotAlbums) return
|
||||
db.createMany(
|
||||
Tables.Album,
|
||||
data.hotAlbums.map((a: Album) => ({
|
||||
id: a.id,
|
||||
json: JSON.stringify(a),
|
||||
updatedAt: Date.now(),
|
||||
}))
|
||||
)
|
||||
const modifiedData = {
|
||||
...data,
|
||||
hotAlbums: data.hotAlbums.map((a: Album) => a.id),
|
||||
}
|
||||
db.upsert(Tables.ArtistAlbum, {
|
||||
id: data.artist.id,
|
||||
json: JSON.stringify(modifiedData),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
break
|
||||
}
|
||||
case APIs.Lyric: {
|
||||
if (!data.lrc) return
|
||||
db.upsert(Tables.Lyric, {
|
||||
id: query.id,
|
||||
json: JSON.stringify(data),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
break
|
||||
}
|
||||
case APIs.CoverColor: {
|
||||
if (!data.id || !data.color) return
|
||||
if (/^#([a-fA-F0-9]){3}$|[a-fA-F0-9]{6}$/.test(data.color) === false) {
|
||||
return
|
||||
}
|
||||
db.upsert(Tables.CoverColor, {
|
||||
id: data.id,
|
||||
color: data.color,
|
||||
queriedAt: Date.now(),
|
||||
})
|
||||
break
|
||||
}
|
||||
case APIs.AppleMusicAlbum: {
|
||||
if (!data.id) return
|
||||
db.upsert(Tables.AppleMusicAlbum, {
|
||||
id: data.id,
|
||||
json: data.album ? JSON.stringify(data.album) : 'no',
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
break
|
||||
}
|
||||
case APIs.AppleMusicArtist: {
|
||||
if (!data) return
|
||||
db.upsert(Tables.AppleMusicArtist, {
|
||||
id: data.id,
|
||||
json: data.artist ? JSON.stringify(data.artist) : 'no',
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get<T extends keyof APIsParams>(api: T, params: any): any {
|
||||
switch (api) {
|
||||
case APIs.UserPlaylist:
|
||||
case APIs.UserAccount:
|
||||
case APIs.Personalized:
|
||||
case APIs.RecommendResource:
|
||||
case APIs.UserArtists:
|
||||
case APIs.ListenedRecords:
|
||||
case APIs.Likelist: {
|
||||
const data = db.find(Tables.AccountData, api)
|
||||
if (data?.json) return JSON.parse(data.json)
|
||||
break
|
||||
}
|
||||
case APIs.Track: {
|
||||
const ids: number[] = params?.ids
|
||||
.split(',')
|
||||
.map((id: string) => Number(id))
|
||||
if (ids.length === 0) return
|
||||
|
||||
if (ids.includes(NaN)) return
|
||||
|
||||
const tracksRaw = db.findMany(Tables.Track, ids)
|
||||
|
||||
if (tracksRaw.length !== ids.length) {
|
||||
return
|
||||
}
|
||||
const tracks = ids.map(id => {
|
||||
const track = tracksRaw.find(t => t.id === Number(id)) as any
|
||||
return JSON.parse(track.json)
|
||||
})
|
||||
return {
|
||||
code: 200,
|
||||
songs: tracks,
|
||||
privileges: {},
|
||||
}
|
||||
}
|
||||
case APIs.Album: {
|
||||
if (isNaN(Number(params?.id))) return
|
||||
const data = db.find(Tables.Album, params.id)
|
||||
if (data?.json)
|
||||
return {
|
||||
resourceState: true,
|
||||
songs: [],
|
||||
code: 200,
|
||||
album: JSON.parse(data.json),
|
||||
}
|
||||
break
|
||||
}
|
||||
case APIs.Playlist: {
|
||||
if (isNaN(Number(params?.id))) return
|
||||
const data = db.find(Tables.Playlist, params.id)
|
||||
if (data?.json) return JSON.parse(data.json)
|
||||
break
|
||||
}
|
||||
case APIs.Artist: {
|
||||
if (isNaN(Number(params?.id))) return
|
||||
const data = db.find(Tables.Artist, params.id)
|
||||
const fromAppleData = db.find(Tables.AppleMusicArtist, params.id)
|
||||
const fromApple = fromAppleData?.json && JSON.parse(fromAppleData.json)
|
||||
const fromNetease = data?.json && JSON.parse(data.json)
|
||||
if (fromNetease && fromApple && fromApple !== 'no') {
|
||||
fromNetease.artist.img1v1Url = fromApple.attributes.artwork.url
|
||||
fromNetease.artist.briefDesc = fromApple.attributes.artistBio
|
||||
}
|
||||
return fromNetease ? fromNetease : undefined
|
||||
}
|
||||
case APIs.ArtistAlbum: {
|
||||
if (isNaN(Number(params?.id))) return
|
||||
|
||||
const artistAlbumsRaw = db.find(Tables.ArtistAlbum, params.id)
|
||||
if (!artistAlbumsRaw?.json) return
|
||||
const artistAlbums = JSON.parse(artistAlbumsRaw.json)
|
||||
|
||||
const albumsRaw = db.findMany(Tables.Album, artistAlbums.hotAlbums)
|
||||
if (albumsRaw.length !== artistAlbums.hotAlbums.length) return
|
||||
const albums = albumsRaw.map(a => JSON.parse(a.json))
|
||||
|
||||
artistAlbums.hotAlbums = artistAlbums.hotAlbums.map((id: number) =>
|
||||
albums.find(a => a.id === id)
|
||||
)
|
||||
return artistAlbums
|
||||
}
|
||||
case APIs.Lyric: {
|
||||
if (isNaN(Number(params?.id))) return
|
||||
const data = db.find(Tables.Lyric, params.id)
|
||||
if (data?.json) return JSON.parse(data.json)
|
||||
break
|
||||
}
|
||||
case APIs.CoverColor: {
|
||||
if (isNaN(Number(params?.id))) return
|
||||
return db.find(Tables.CoverColor, params.id)?.color
|
||||
}
|
||||
case APIs.Artists: {
|
||||
if (!params.ids?.length) return
|
||||
const artists = db.findMany(Tables.Artist, params.ids)
|
||||
if (artists.length !== params.ids.length) return
|
||||
const result = artists.map(a => JSON.parse(a.json))
|
||||
result.sort((a, b) => {
|
||||
const indexA: number = params.ids.indexOf(a.artist.id)
|
||||
const indexB: number = params.ids.indexOf(b.artist.id)
|
||||
return indexA - indexB
|
||||
})
|
||||
return result
|
||||
}
|
||||
case APIs.AppleMusicAlbum: {
|
||||
if (isNaN(Number(params?.id))) return
|
||||
const data = db.find(Tables.AppleMusicAlbum, params.id)
|
||||
if (data?.json && data.json !== 'no') return JSON.parse(data.json)
|
||||
break
|
||||
}
|
||||
case APIs.AppleMusicArtist: {
|
||||
if (isNaN(Number(params?.id))) return
|
||||
const data = db.find(Tables.AppleMusicArtist, params.id)
|
||||
if (data?.json && data.json !== 'no') return JSON.parse(data.json)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getForExpress(api: string, req: Request) {
|
||||
// Get track detail cache
|
||||
if (api === APIs.Track) {
|
||||
const cache = this.get(api, req.query)
|
||||
if (cache) {
|
||||
log.debug(`[cache] Cache hit for ${req.path}`)
|
||||
return cache
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getAudio(fileName: string, res: Response) {
|
||||
if (!fileName) {
|
||||
return res.status(400).send({ error: 'No filename provided' })
|
||||
}
|
||||
const id = Number(fileName.split('-')[0])
|
||||
|
||||
try {
|
||||
const path = `${app.getPath('userData')}/audio_cache/${fileName}`
|
||||
const audio = fs.readFileSync(path)
|
||||
if (audio.byteLength === 0) {
|
||||
db.delete(Tables.Audio, id)
|
||||
fs.unlinkSync(path)
|
||||
return res.status(404).send({ error: 'Audio not found' })
|
||||
}
|
||||
res
|
||||
.status(206)
|
||||
.setHeader('Accept-Ranges', 'bytes')
|
||||
.setHeader('Connection', 'keep-alive')
|
||||
.setHeader(
|
||||
'Content-Range',
|
||||
`bytes 0-${audio.byteLength - 1}/${audio.byteLength}`
|
||||
)
|
||||
.send(audio)
|
||||
} catch (error) {
|
||||
res.status(500).send({ error })
|
||||
}
|
||||
}
|
||||
|
||||
async setAudio(buffer: Buffer, { id, url }: { id: number; url: string }) {
|
||||
const path = `${app.getPath('userData')}/audio_cache`
|
||||
|
||||
try {
|
||||
fs.statSync(path)
|
||||
} catch (e) {
|
||||
fs.mkdirSync(path)
|
||||
}
|
||||
|
||||
const meta = await musicMetadata.parseBuffer(buffer)
|
||||
const br =
|
||||
meta?.format?.codec === 'OPUS' ? 165000 : meta.format.bitrate ?? 0
|
||||
const type =
|
||||
{
|
||||
'MPEG 1 Layer 3': 'mp3',
|
||||
'Ogg Vorbis': 'ogg',
|
||||
AAC: 'm4a',
|
||||
FLAC: 'flac',
|
||||
OPUS: 'opus',
|
||||
}[meta.format.codec ?? ''] ?? 'unknown'
|
||||
|
||||
let source: TablesStructures[Tables.Audio]['source'] = 'unknown'
|
||||
if (url.includes('googlevideo.com')) source = 'youtube'
|
||||
if (url.includes('126.net')) source = 'netease'
|
||||
if (url.includes('migu.cn')) source = 'migu'
|
||||
if (url.includes('kuwo.cn')) source = 'kuwo'
|
||||
if (url.includes('bilivideo.com')) source = 'bilibili'
|
||||
// TODO: missing kugou qq joox
|
||||
|
||||
fs.writeFile(`${path}/${id}-${br}.${type}`, buffer, error => {
|
||||
if (error) {
|
||||
return log.error(`[cache] cacheAudio failed: ${error}`)
|
||||
}
|
||||
log.info(`Audio file ${id}-${br}.${type} cached!`)
|
||||
|
||||
db.upsert(Tables.Audio, {
|
||||
id,
|
||||
br,
|
||||
type: type as TablesStructures[Tables.Audio]['type'],
|
||||
source,
|
||||
queriedAt: Date.now(),
|
||||
})
|
||||
|
||||
log.info(`[cache] cacheAudio ${id}-${br}.${type}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default new Cache()
|
||||
226
packages/desktop/main/db.ts
Normal file
226
packages/desktop/main/db.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
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 pkg from '../../../package.json'
|
||||
import { compare, validate } from 'compare-versions'
|
||||
|
||||
export const enum Tables {
|
||||
Track = 'Track',
|
||||
Album = 'Album',
|
||||
Artist = 'Artist',
|
||||
Playlist = 'Playlist',
|
||||
ArtistAlbum = 'ArtistAlbum',
|
||||
Lyric = 'Lyric',
|
||||
Audio = 'Audio',
|
||||
AccountData = 'AccountData',
|
||||
CoverColor = 'CoverColor',
|
||||
AppData = 'AppData',
|
||||
AppleMusicAlbum = 'AppleMusicAlbum',
|
||||
AppleMusicArtist = 'AppleMusicArtist',
|
||||
}
|
||||
interface CommonTableStructure {
|
||||
id: number
|
||||
json: string
|
||||
updatedAt: number
|
||||
}
|
||||
export interface TablesStructures {
|
||||
[Tables.Track]: CommonTableStructure
|
||||
[Tables.Album]: CommonTableStructure
|
||||
[Tables.Artist]: CommonTableStructure
|
||||
[Tables.Playlist]: CommonTableStructure
|
||||
[Tables.ArtistAlbum]: CommonTableStructure
|
||||
[Tables.Lyric]: CommonTableStructure
|
||||
[Tables.AccountData]: {
|
||||
id: string
|
||||
json: string
|
||||
updatedAt: number
|
||||
}
|
||||
[Tables.Audio]: {
|
||||
id: number
|
||||
br: number
|
||||
type: 'mp3' | 'flac' | 'ogg' | 'wav' | 'm4a' | 'aac' | 'unknown' | 'opus'
|
||||
source:
|
||||
| 'unknown'
|
||||
| 'netease'
|
||||
| 'migu'
|
||||
| 'kuwo'
|
||||
| 'kugou'
|
||||
| 'youtube'
|
||||
| 'qq'
|
||||
| 'bilibili'
|
||||
| 'joox'
|
||||
queriedAt: number
|
||||
}
|
||||
[Tables.CoverColor]: {
|
||||
id: number
|
||||
color: string
|
||||
queriedAt: number
|
||||
}
|
||||
[Tables.AppData]: {
|
||||
id: 'appVersion' | 'skippedVersion'
|
||||
value: string
|
||||
}
|
||||
[Tables.AppleMusicAlbum]: CommonTableStructure
|
||||
[Tables.AppleMusicArtist]: CommonTableStructure
|
||||
}
|
||||
|
||||
type TableNames = keyof TablesStructures
|
||||
|
||||
const readSqlFile = (filename: string) => {
|
||||
return fs.readFileSync(path.join(dirname, `./migrations/${filename}`), 'utf8')
|
||||
}
|
||||
|
||||
class DB {
|
||||
sqlite: SQLite3.Database
|
||||
dbFilePath: string = path.resolve(
|
||||
app.getPath('userData'),
|
||||
'./api_cache/db.sqlite'
|
||||
)
|
||||
|
||||
constructor() {
|
||||
log.info('[db] Initializing database...')
|
||||
|
||||
createFileIfNotExist(this.dbFilePath)
|
||||
|
||||
this.sqlite = new SQLite3(this.dbFilePath, {
|
||||
nativeBinding: path.join(
|
||||
__dirname,
|
||||
`./binary/better_sqlite3_${process.arch}.node`
|
||||
),
|
||||
})
|
||||
this.sqlite.pragma('auto_vacuum = FULL')
|
||||
this.initTables()
|
||||
this.migrate()
|
||||
|
||||
log.info('[db] Database initialized')
|
||||
}
|
||||
|
||||
initTables() {
|
||||
const init = readSqlFile('init.sql')
|
||||
this.sqlite.exec(init)
|
||||
}
|
||||
|
||||
migrate() {
|
||||
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()
|
||||
}
|
||||
|
||||
find<T extends TableNames>(
|
||||
table: T,
|
||||
key: TablesStructures[T]['id']
|
||||
): TablesStructures[T] | undefined {
|
||||
return this.sqlite
|
||||
.prepare(`SELECT * FROM ${table} WHERE id = ? LIMIT 1`)
|
||||
.get(key)
|
||||
}
|
||||
|
||||
findMany<T extends TableNames>(
|
||||
table: T,
|
||||
keys: TablesStructures[T]['id'][]
|
||||
): TablesStructures[T][] {
|
||||
const idsQuery = keys.map(key => `id = ${key}`).join(' OR ')
|
||||
return this.sqlite.prepare(`SELECT * FROM ${table} WHERE ${idsQuery}`).all()
|
||||
}
|
||||
|
||||
findAll<T extends TableNames>(table: T): TablesStructures[T][] {
|
||||
return this.sqlite.prepare(`SELECT * FROM ${table}`).all()
|
||||
}
|
||||
|
||||
create<T extends TableNames>(
|
||||
table: T,
|
||||
data: TablesStructures[T],
|
||||
skipWhenExist: boolean = true
|
||||
) {
|
||||
if (skipWhenExist && db.find(table, data.id)) return
|
||||
return this.sqlite.prepare(`INSERT INTO ${table} VALUES (?)`).run(data)
|
||||
}
|
||||
|
||||
createMany<T extends TableNames>(
|
||||
table: T,
|
||||
data: TablesStructures[T][],
|
||||
skipWhenExist: boolean = true
|
||||
) {
|
||||
const valuesQuery = Object.keys(data[0])
|
||||
.map(key => `:${key}`)
|
||||
.join(', ')
|
||||
const insert = this.sqlite.prepare(
|
||||
`INSERT ${
|
||||
skipWhenExist ? 'OR IGNORE' : ''
|
||||
} INTO ${table} VALUES (${valuesQuery})`
|
||||
)
|
||||
const insertMany = this.sqlite.transaction((rows: any[]) => {
|
||||
rows.forEach((row: any) => insert.run(row))
|
||||
})
|
||||
insertMany(data)
|
||||
}
|
||||
|
||||
upsert<T extends TableNames>(table: T, data: TablesStructures[T]) {
|
||||
const valuesQuery = Object.keys(data)
|
||||
.map(key => `:${key}`)
|
||||
.join(', ')
|
||||
return this.sqlite
|
||||
.prepare(`INSERT OR REPLACE INTO ${table} VALUES (${valuesQuery})`)
|
||||
.run(data)
|
||||
}
|
||||
|
||||
upsertMany<T extends TableNames>(table: T, data: TablesStructures[T][]) {
|
||||
const valuesQuery = Object.keys(data[0])
|
||||
.map(key => `:${key}`)
|
||||
.join(', ')
|
||||
const upsert = this.sqlite.prepare(
|
||||
`INSERT OR REPLACE INTO ${table} VALUES (${valuesQuery})`
|
||||
)
|
||||
const upsertMany = this.sqlite.transaction((rows: any[]) => {
|
||||
rows.forEach((row: any) => upsert.run(row))
|
||||
})
|
||||
upsertMany(data)
|
||||
}
|
||||
|
||||
delete<T extends TableNames>(table: T, key: TablesStructures[T]['id']) {
|
||||
return this.sqlite.prepare(`DELETE FROM ${table} WHERE id = ?`).run(key)
|
||||
}
|
||||
|
||||
deleteMany<T extends TableNames>(
|
||||
table: T,
|
||||
keys: TablesStructures[T]['id'][]
|
||||
) {
|
||||
const idsQuery = keys.map(key => `id = ${key}`).join(' OR ')
|
||||
return this.sqlite.prepare(`DELETE FROM ${table} WHERE ${idsQuery}`).run()
|
||||
}
|
||||
|
||||
truncate<T extends TableNames>(table: T) {
|
||||
return this.sqlite.prepare(`DELETE FROM ${table}`).run()
|
||||
}
|
||||
|
||||
vacuum() {
|
||||
return this.sqlite.prepare('VACUUM').run()
|
||||
}
|
||||
}
|
||||
|
||||
export const db = new DB()
|
||||
217
packages/desktop/main/index.ts
Normal file
217
packages/desktop/main/index.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import './preload' // must be first
|
||||
import './sentry'
|
||||
import './server'
|
||||
import {
|
||||
BrowserWindow,
|
||||
BrowserWindowConstructorOptions,
|
||||
app,
|
||||
shell,
|
||||
} from 'electron'
|
||||
import { release } from 'os'
|
||||
import { join } from 'path'
|
||||
import log from './log'
|
||||
import { initIpcMain } from './ipcMain'
|
||||
import { createTray, YPMTray } from './tray'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { createTaskbar, Thumbar } from './windowsTaskbar'
|
||||
import { createMenu } from './menu'
|
||||
import { isDev, isWindows, isLinux, isMac } from './utils'
|
||||
import store from './store'
|
||||
import Airplay from './airplay'
|
||||
|
||||
class Main {
|
||||
win: BrowserWindow | null = null
|
||||
tray: YPMTray | null = null
|
||||
thumbar: Thumbar | null = null
|
||||
|
||||
constructor() {
|
||||
log.info('[index] Main process start')
|
||||
|
||||
// Disable GPU Acceleration for Windows 7
|
||||
if (release().startsWith('6.1')) app.disableHardwareAcceleration()
|
||||
|
||||
// Set application name for Windows 10+ notifications
|
||||
if (process.platform === 'win32') app.setAppUserModelId(app.getName())
|
||||
|
||||
// Make sure the app only run on one instance
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.quit()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
log.info('[index] App ready')
|
||||
this.createWindow()
|
||||
this.handleAppEvents()
|
||||
this.handleWindowEvents()
|
||||
this.createTray()
|
||||
createMenu(this.win)
|
||||
this.createThumbar()
|
||||
initIpcMain(this.win, this.tray, this.thumbar, store)
|
||||
this.initDevTools()
|
||||
new Airplay(this.win)
|
||||
})
|
||||
}
|
||||
|
||||
initDevTools() {
|
||||
if (!isDev || !this.win) return
|
||||
|
||||
// Install devtool extension
|
||||
const {
|
||||
default: installExtension,
|
||||
REACT_DEVELOPER_TOOLS,
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
} = require('electron-devtools-installer')
|
||||
installExtension(REACT_DEVELOPER_TOOLS.id).catch((err: unknown) =>
|
||||
log.info('An error occurred: ', err)
|
||||
)
|
||||
|
||||
this.win.webContents.openDevTools()
|
||||
}
|
||||
|
||||
createTray() {
|
||||
if (isWindows || isLinux || isDev) {
|
||||
this.tray = createTray(this.win!)
|
||||
}
|
||||
}
|
||||
|
||||
createThumbar() {
|
||||
if (isWindows) this.thumbar = createTaskbar(this.win!)
|
||||
}
|
||||
|
||||
createWindow() {
|
||||
const options: BrowserWindowConstructorOptions = {
|
||||
title: 'YesPlayMusic',
|
||||
webPreferences: {
|
||||
preload: join(__dirname, 'rendererPreload.js'),
|
||||
},
|
||||
width: store.get('window.width'),
|
||||
height: store.get('window.height'),
|
||||
minWidth: 1240,
|
||||
minHeight: 848,
|
||||
titleBarStyle: isMac ? 'customButtonsOnHover' : 'hidden',
|
||||
trafficLightPosition: { x: 24, y: 24 },
|
||||
frame: false,
|
||||
transparent: true,
|
||||
}
|
||||
if (store.get('window')) {
|
||||
options.x = store.get('window.x')
|
||||
options.y = store.get('window.y')
|
||||
}
|
||||
this.win = new BrowserWindow(options)
|
||||
|
||||
// Web server
|
||||
const url = `http://localhost:${process.env.ELECTRON_WEB_SERVER_PORT}`
|
||||
this.win.loadURL(url)
|
||||
|
||||
// Make all links open with the browser, not with the application
|
||||
this.win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (url.startsWith('https:')) shell.openExternal(url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
this.disableCORS()
|
||||
}
|
||||
|
||||
disableCORS() {
|
||||
if (!this.win) return
|
||||
|
||||
const addCORSHeaders = (headers: Record<string, string | string[]>) => {
|
||||
if (
|
||||
headers['Access-Control-Allow-Origin']?.[0] !== '*' &&
|
||||
headers['access-control-allow-origin']?.[0] !== '*'
|
||||
) {
|
||||
headers['Access-Control-Allow-Origin'] = ['*']
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
this.win.webContents.session.webRequest.onBeforeSendHeaders(
|
||||
(details, callback) => {
|
||||
const { requestHeaders, url } = details
|
||||
addCORSHeaders(requestHeaders)
|
||||
|
||||
// 不加这几个 header 的话,使用 axios 加载 YouTube 音频会很慢
|
||||
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, url } = details
|
||||
if (url.includes('sentry.io')) {
|
||||
callback({ responseHeaders })
|
||||
return
|
||||
}
|
||||
if (responseHeaders) {
|
||||
addCORSHeaders(responseHeaders)
|
||||
}
|
||||
callback({ responseHeaders })
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
handleWindowEvents() {
|
||||
if (!this.win) return
|
||||
|
||||
// Window maximize and minimize
|
||||
this.win.on('maximize', () => {
|
||||
this.win && this.win.webContents.send(IpcChannels.IsMaximized, true)
|
||||
})
|
||||
|
||||
this.win.on('unmaximize', () => {
|
||||
this.win && this.win.webContents.send(IpcChannels.IsMaximized, false)
|
||||
})
|
||||
|
||||
this.win.on('enter-full-screen', () => {
|
||||
this.win &&
|
||||
this.win.webContents.send(IpcChannels.FullscreenStateChange, true)
|
||||
})
|
||||
|
||||
this.win.on('leave-full-screen', () => {
|
||||
this.win &&
|
||||
this.win.webContents.send(IpcChannels.FullscreenStateChange, false)
|
||||
})
|
||||
|
||||
// Save window position
|
||||
const saveBounds = () => {
|
||||
const bounds = this.win?.getBounds()
|
||||
if (bounds) {
|
||||
store.set('window', bounds)
|
||||
}
|
||||
}
|
||||
this.win.on('resized', saveBounds)
|
||||
this.win.on('moved', saveBounds)
|
||||
}
|
||||
|
||||
handleAppEvents() {
|
||||
app.on('window-all-closed', () => {
|
||||
this.win = null
|
||||
if (!isMac) app.quit()
|
||||
})
|
||||
|
||||
app.on('second-instance', () => {
|
||||
if (!this.win) return
|
||||
// Focus on the main window if the user tried to open another
|
||||
if (this.win.isMinimized()) this.win.restore()
|
||||
this.win.focus()
|
||||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
const allWindows = BrowserWindow.getAllWindows()
|
||||
if (allWindows.length) {
|
||||
allWindows[0].focus()
|
||||
} else {
|
||||
this.createWindow()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
new Main()
|
||||
227
packages/desktop/main/ipcMain.ts
Normal file
227
packages/desktop/main/ipcMain.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import { BrowserWindow, ipcMain, app } from 'electron'
|
||||
import { db, Tables } from './db'
|
||||
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 './store'
|
||||
import { APIs } from '@/shared/CacheAPIs'
|
||||
import { YPMTray } from './tray'
|
||||
import { Thumbar } from './windowsTaskbar'
|
||||
import fastFolderSize from 'fast-folder-size'
|
||||
import path from 'path'
|
||||
import prettyBytes from 'pretty-bytes'
|
||||
import { getArtist, getAlbum } from './appleMusic'
|
||||
|
||||
const on = <T extends keyof IpcChannelsParams>(
|
||||
channel: T,
|
||||
listener: (event: Electron.IpcMainEvent, params: IpcChannelsParams[T]) => void
|
||||
) => {
|
||||
ipcMain.on(channel, listener)
|
||||
}
|
||||
|
||||
const handle = <T extends keyof IpcChannelsParams>(
|
||||
channel: T,
|
||||
listener: (
|
||||
event: Electron.IpcMainInvokeEvent,
|
||||
params: IpcChannelsParams[T]
|
||||
) => void
|
||||
) => {
|
||||
return ipcMain.handle(channel, listener)
|
||||
}
|
||||
|
||||
export function initIpcMain(
|
||||
win: BrowserWindow | null,
|
||||
tray: YPMTray | null,
|
||||
thumbar: Thumbar | null,
|
||||
store: Store<TypedElectronStore>
|
||||
) {
|
||||
initWindowIpcMain(win)
|
||||
initTrayIpcMain(tray)
|
||||
initTaskbarIpcMain(thumbar)
|
||||
initStoreIpcMain(store)
|
||||
initOtherIpcMain()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理需要win对象的事件
|
||||
* @param {BrowserWindow} win
|
||||
*/
|
||||
function initWindowIpcMain(win: BrowserWindow | null) {
|
||||
on(IpcChannels.Minimize, () => {
|
||||
win?.minimize()
|
||||
})
|
||||
|
||||
on(IpcChannels.MaximizeOrUnmaximize, () => {
|
||||
if (!win) return
|
||||
win.isMaximized() ? win.unmaximize() : win.maximize()
|
||||
})
|
||||
|
||||
on(IpcChannels.Close, () => {
|
||||
app.exit()
|
||||
})
|
||||
|
||||
on(IpcChannels.ResetWindowSize, () => {
|
||||
if (!win) return
|
||||
win?.setSize(1440, 1024, true)
|
||||
})
|
||||
|
||||
on(IpcChannels.IsMaximized, e => {
|
||||
if (!win) return
|
||||
e.returnValue = win.isMaximized()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理需要tray对象的事件
|
||||
* @param {YPMTray} tray
|
||||
*/
|
||||
function initTrayIpcMain(tray: YPMTray | null) {
|
||||
on(IpcChannels.SetTrayTooltip, (e, { text }) => tray?.setTooltip(text))
|
||||
|
||||
on(IpcChannels.Like, (e, { isLiked }) => tray?.setLikeState(isLiked))
|
||||
|
||||
on(IpcChannels.Play, () => tray?.setPlayState(true))
|
||||
on(IpcChannels.Pause, () => tray?.setPlayState(false))
|
||||
|
||||
on(IpcChannels.Repeat, (e, { mode }) => tray?.setRepeatMode(mode))
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理需要thumbar对象的事件
|
||||
* @param {Thumbar} thumbar
|
||||
*/
|
||||
function initTaskbarIpcMain(thumbar: Thumbar | null) {
|
||||
on(IpcChannels.Play, () => thumbar?.setPlayState(true))
|
||||
on(IpcChannels.Pause, () => thumbar?.setPlayState(false))
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理需要electron-store的事件
|
||||
* @param {Store<TypedElectronStore>} store
|
||||
*/
|
||||
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 })
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取音频缓存文件夹大小
|
||||
*/
|
||||
on(IpcChannels.GetAudioCacheSize, event => {
|
||||
fastFolderSize(
|
||||
path.join(app.getPath('userData'), './audio_cache'),
|
||||
(error, bytes) => {
|
||||
if (error) throw error
|
||||
|
||||
event.returnValue = prettyBytes(bytes ?? 0)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* 从Apple Music获取专辑信息
|
||||
*/
|
||||
handle(
|
||||
IpcChannels.GetAlbumFromAppleMusic,
|
||||
async (event, { id, name, artist }) => {
|
||||
const fromCache = cache.get(APIs.AppleMusicAlbum, { id })
|
||||
if (fromCache) {
|
||||
return fromCache === 'no' ? undefined : fromCache
|
||||
}
|
||||
|
||||
const fromApple = await getAlbum({ name, artist })
|
||||
cache.set(APIs.AppleMusicAlbum, { id, album: fromApple })
|
||||
return fromApple
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 从Apple Music获取歌手信息
|
||||
**/
|
||||
handle(IpcChannels.GetArtistFromAppleMusic, async (event, { id, name }) => {
|
||||
const fromApple = await getArtist(name)
|
||||
cache.set(APIs.AppleMusicArtist, { id, artist: fromApple })
|
||||
return fromApple
|
||||
})
|
||||
|
||||
/**
|
||||
* 从缓存读取Apple Music歌手信息
|
||||
*/
|
||||
on(IpcChannels.GetArtistFromAppleMusic, (event, { id }) => {
|
||||
const artist = cache.get(APIs.AppleMusicArtist, id)
|
||||
event.returnValue = artist === 'no' ? undefined : artist
|
||||
})
|
||||
|
||||
/**
|
||||
* 导出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!')
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
31
packages/desktop/main/log.ts
Normal file
31
packages/desktop/main/log.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/** By default, it writes logs to the following locations:
|
||||
* on Linux: ~/.config/{app name}/logs/{process type}.log
|
||||
* on macOS: ~/Library/Logs/{app name}/{process type}.log
|
||||
* on Windows: %USERPROFILE%\AppData\Roaming\{app name}\logs\{process type}.log
|
||||
* @see https://www.npmjs.com/package/electron-log
|
||||
*/
|
||||
|
||||
import log from 'electron-log'
|
||||
import pc from 'picocolors'
|
||||
import { isDev } from './utils'
|
||||
|
||||
Object.assign(console, log.functions)
|
||||
log.variables.process = 'main'
|
||||
if (log.transports.ipc) log.transports.ipc.level = false
|
||||
log.transports.console.format = `${
|
||||
isDev ? '' : pc.dim('{h}:{i}:{s}{scope} ')
|
||||
}{level} › {text}`
|
||||
log.transports.file.level = 'info'
|
||||
|
||||
log.info(
|
||||
`\n\n██╗ ██╗███████╗███████╗██████╗ ██╗ █████╗ ██╗ ██╗███╗ ███╗██╗ ██╗███████╗██╗ ██████╗
|
||||
╚██╗ ██╔╝██╔════╝██╔════╝██╔══██╗██║ ██╔══██╗╚██╗ ██╔╝████╗ ████║██║ ██║██╔════╝██║██╔════╝
|
||||
╚████╔╝ █████╗ ███████╗██████╔╝██║ ███████║ ╚████╔╝ ██╔████╔██║██║ ██║███████╗██║██║
|
||||
╚██╔╝ ██╔══╝ ╚════██║██╔═══╝ ██║ ██╔══██║ ╚██╔╝ ██║╚██╔╝██║██║ ██║╚════██║██║██║
|
||||
██║ ███████╗███████║██║ ███████╗██║ ██║ ██║ ██║ ╚═╝ ██║╚██████╔╝███████║██║╚██████╗
|
||||
╚═╝ ╚══════╝╚══════╝╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═════╝\n`
|
||||
)
|
||||
|
||||
export default log
|
||||
|
||||
log.info(`[logger] logger initialized`)
|
||||
87
packages/desktop/main/menu.ts
Normal file
87
packages/desktop/main/menu.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuItemConstructorOptions,
|
||||
shell,
|
||||
} from 'electron'
|
||||
import { logsPath, isMac } from './utils'
|
||||
import { exec } from 'child_process'
|
||||
|
||||
export const createMenu = (win: BrowserWindow) => {
|
||||
const template: Array<MenuItemConstructorOptions | MenuItem> = [
|
||||
{ role: 'appMenu' },
|
||||
{ role: 'editMenu' },
|
||||
{ role: 'viewMenu' },
|
||||
{ role: 'windowMenu' },
|
||||
{
|
||||
label: '帮助',
|
||||
submenu: [
|
||||
{
|
||||
label: '打开日志文件目录',
|
||||
click: async () => {
|
||||
if (isMac) {
|
||||
exec(`open ${logsPath}`)
|
||||
} else {
|
||||
// TODO: 测试Windows和Linux是否能正确打开日志目录
|
||||
shell.openPath(logsPath)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '打开应用数据目录',
|
||||
click: async () => {
|
||||
const path = app.getPath('userData')
|
||||
if (isMac) {
|
||||
exec(`open ${path}`)
|
||||
} else {
|
||||
// TODO: 测试Windows和Linux是否能正确打开日志目录
|
||||
shell.openPath(path)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '打开开发者工具',
|
||||
click: async () => {
|
||||
win.webContents.openDevTools()
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '反馈问题',
|
||||
click: async () => {
|
||||
await shell.openExternal(
|
||||
'https://github.com/qier222/YesPlayMusic/issues/new'
|
||||
)
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: '访问 GitHub 仓库',
|
||||
click: async () => {
|
||||
await shell.openExternal('https://github.com/qier222/YesPlayMusic')
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '访问论坛',
|
||||
click: async () => {
|
||||
await shell.openExternal(
|
||||
'https://github.com/qier222/YesPlayMusic/discussions'
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '加入交流群',
|
||||
click: async () => {
|
||||
await shell.openExternal(
|
||||
'https://github.com/qier222/YesPlayMusic/discussions'
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const menu = Menu.buildFromTemplate(template)
|
||||
Menu.setApplicationMenu(menu)
|
||||
}
|
||||
19
packages/desktop/main/preload.ts
Normal file
19
packages/desktop/main/preload.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import log from './log'
|
||||
import { app } from 'electron'
|
||||
import {
|
||||
createDirIfNotExist,
|
||||
devUserDataPath,
|
||||
isDev,
|
||||
portableUserDataPath,
|
||||
} from './utils'
|
||||
|
||||
if (isDev) {
|
||||
createDirIfNotExist(devUserDataPath)
|
||||
app.setPath('appData', devUserDataPath)
|
||||
}
|
||||
if (process.env.PORTABLE_EXECUTABLE_DIR) {
|
||||
createDirIfNotExist(portableUserDataPath)
|
||||
app.setPath('appData', portableUserDataPath)
|
||||
}
|
||||
|
||||
log.info(`[index] userData path: ${app.getPath('userData')}`)
|
||||
34
packages/desktop/main/rendererPreload.ts
Normal file
34
packages/desktop/main/rendererPreload.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { isLinux, isMac, isWindows } from './utils'
|
||||
const { contextBridge, ipcRenderer } = require('electron')
|
||||
const log = require('electron-log')
|
||||
|
||||
log.transports.file.level = 'info'
|
||||
log.transports.ipc.level = false
|
||||
log.variables.process = 'renderer'
|
||||
contextBridge.exposeInMainWorld('log', log)
|
||||
|
||||
contextBridge.exposeInMainWorld('ipcRenderer', {
|
||||
sendSync: ipcRenderer.sendSync,
|
||||
invoke: ipcRenderer.invoke,
|
||||
send: ipcRenderer.send,
|
||||
on: (
|
||||
channel: IpcChannels,
|
||||
listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void
|
||||
) => {
|
||||
ipcRenderer.on(channel, listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener(channel, listener)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
contextBridge.exposeInMainWorld('env', {
|
||||
isElectron: true,
|
||||
isEnableTitlebar:
|
||||
process.platform === 'win32' || process.platform === 'linux',
|
||||
isLinux,
|
||||
isMac,
|
||||
isWindows,
|
||||
})
|
||||
18
packages/desktop/main/sentry.ts
Normal file
18
packages/desktop/main/sentry.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import * as Sentry from '@sentry/electron'
|
||||
import pkg from '../../../package.json'
|
||||
import log from './log'
|
||||
|
||||
log.info(`[sentry] sentry initializing`)
|
||||
|
||||
Sentry.init({
|
||||
dsn: 'https://2aaaa67f1c3d4d6baefafa5d58fcf340@o436528.ingest.sentry.io/6274637',
|
||||
release: `yesplaymusic@${pkg.version}`,
|
||||
environment: process.env.NODE_ENV,
|
||||
|
||||
// Set tracesSampleRate to 1.0 to capture 100%
|
||||
// of transactions for performance monitoring.
|
||||
// We recommend adjusting this value in production
|
||||
tracesSampleRate: 1.0,
|
||||
})
|
||||
|
||||
log.info(`[sentry] sentry initialized`)
|
||||
342
packages/desktop/main/server.ts
Normal file
342
packages/desktop/main/server.ts
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
import { pathCase } from 'change-case'
|
||||
import cookieParser from 'cookie-parser'
|
||||
import express, { Request, Response } from 'express'
|
||||
import log from './log'
|
||||
import cache from './cache'
|
||||
import fileUpload from 'express-fileupload'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { db, Tables } from './db'
|
||||
import { app } from 'electron'
|
||||
import type { FetchAudioSourceResponse } from '@/shared/api/Track'
|
||||
import UNM from '@unblockneteasemusic/rust-napi'
|
||||
import { APIs as CacheAPIs } from '@/shared/CacheAPIs'
|
||||
import { isProd } from './utils'
|
||||
import { APIs } from '@/shared/CacheAPIs'
|
||||
import history from 'connect-history-api-fallback'
|
||||
|
||||
class Server {
|
||||
port = Number(
|
||||
isProd
|
||||
? process.env.ELECTRON_WEB_SERVER_PORT ?? 42710
|
||||
: process.env.ELECTRON_DEV_NETEASE_API_PORT ?? 3000
|
||||
)
|
||||
app = express()
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
netease = require('NeteaseCloudMusicApi') as any
|
||||
|
||||
constructor() {
|
||||
log.info('[server] starting http server')
|
||||
this.app.use(cookieParser())
|
||||
this.app.use(fileUpload())
|
||||
this.getAudioUrlHandler()
|
||||
this.neteaseHandler()
|
||||
this.cacheAudioHandler()
|
||||
this.serveStaticForProduction()
|
||||
this.listen()
|
||||
}
|
||||
|
||||
neteaseHandler() {
|
||||
Object.entries(this.netease).forEach(([name, handler]: [string, any]) => {
|
||||
// 例外处理
|
||||
if (
|
||||
['serveNcmApi', 'getModulesDefinitions', APIs.SongUrl].includes(name)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
name = pathCase(name)
|
||||
|
||||
const wrappedHandler = async (req: Request, res: Response) => {
|
||||
log.debug(`[server] Handling request: ${req.path}`)
|
||||
|
||||
// Get from cache
|
||||
const cacheData = await cache.getForExpress(name, req)
|
||||
if (cacheData) return res.json(cacheData)
|
||||
|
||||
// Request netease api
|
||||
try {
|
||||
const result = await handler({
|
||||
...req.query,
|
||||
cookie: req.cookies,
|
||||
})
|
||||
|
||||
cache.set(name, result.body, req.query)
|
||||
return res.send(result.body)
|
||||
} catch (error: any) {
|
||||
if ([400, 301].includes(error.status)) {
|
||||
return res.status(error.status).send(error.body)
|
||||
}
|
||||
return res.status(500)
|
||||
}
|
||||
}
|
||||
|
||||
this.app.get(`/netease/${name}`, wrappedHandler)
|
||||
this.app.post(`/netease/${name}`, wrappedHandler)
|
||||
})
|
||||
}
|
||||
|
||||
serveStaticForProduction() {
|
||||
if (isProd) {
|
||||
this.app.use(history())
|
||||
this.app.use(express.static(path.join(__dirname, '../web')))
|
||||
}
|
||||
}
|
||||
|
||||
getAudioUrlHandler() {
|
||||
const getFromCache = (id: number) => {
|
||||
// get from cache
|
||||
const cache = db.find(Tables.Audio, id)
|
||||
if (!cache) return
|
||||
|
||||
const audioFileName = `${cache.id}-${cache.br}.${cache.type}`
|
||||
|
||||
const isAudioFileExists = fs.existsSync(
|
||||
`${app.getPath('userData')}/audio_cache/${audioFileName}`
|
||||
)
|
||||
if (!isAudioFileExists) return
|
||||
|
||||
log.debug(`[server] Audio cache hit for song/url`)
|
||||
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
source: cache.source,
|
||||
id: cache.id,
|
||||
url: `http://127.0.0.1:42710/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) {
|
||||
log.debug(
|
||||
`[server] UMN match: ${matchedAudio.song?.name} (https://youtube.com/v/${matchedAudio.song?.id})`
|
||||
)
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
source,
|
||||
id,
|
||||
url: retrievedSong.url,
|
||||
br: 128000,
|
||||
size: 0,
|
||||
md5: '',
|
||||
code: 200,
|
||||
expi: 0,
|
||||
type: 'unknown',
|
||||
gain: 0,
|
||||
fee: 8,
|
||||
uf: null,
|
||||
payed: 0,
|
||||
flag: 4,
|
||||
canExtend: false,
|
||||
freeTrialInfo: null,
|
||||
level: 'standard',
|
||||
encodeType: 'unknown',
|
||||
freeTrialPrivilege: {
|
||||
resConsumable: false,
|
||||
userConsumable: false,
|
||||
listenType: null,
|
||||
},
|
||||
freeTimeTrialPrivilege: {
|
||||
resConsumable: false,
|
||||
userConsumable: false,
|
||||
type: 0,
|
||||
remainTime: 0,
|
||||
},
|
||||
urlSource: 0,
|
||||
unm: {
|
||||
source,
|
||||
song: matchedAudio.song,
|
||||
},
|
||||
},
|
||||
],
|
||||
code: 200,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handler = async (req: Request, res: Response) => {
|
||||
const id = Number(req.query.id) || 0
|
||||
if (id === 0) {
|
||||
return res.status(400).send({
|
||||
code: 400,
|
||||
msg: 'id is required or id is invalid',
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const fromCache = await getFromCache(id)
|
||||
if (fromCache) {
|
||||
res.status(200).send(fromCache)
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`[server] getFromCache failed: ${String(error)}`)
|
||||
}
|
||||
|
||||
const fromNetease = await getFromNetease(req)
|
||||
if (
|
||||
fromNetease?.code === 200 &&
|
||||
!fromNetease?.data?.[0]?.freeTrialInfo &&
|
||||
fromNetease?.data?.[0]?.url
|
||||
) {
|
||||
res.status(200).send(fromNetease)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const fromUNM = await getFromUNM(id, req)
|
||||
if (fromUNM) {
|
||||
res.status(200).send(fromUNM)
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`[server] getFromUNM failed: ${String(error)}`)
|
||||
}
|
||||
|
||||
if (fromNetease?.data?.[0].freeTrialInfo) {
|
||||
fromNetease.data[0].url = ''
|
||||
}
|
||||
|
||||
res.status(fromNetease?.code ?? 500).send(fromNetease)
|
||||
}
|
||||
|
||||
this.app.get('/netease/song/url', handler)
|
||||
}
|
||||
|
||||
cacheAudioHandler() {
|
||||
this.app.get(
|
||||
'/yesplaymusic/audio/:filename',
|
||||
async (req: Request, res: Response) => {
|
||||
cache.getAudio(req.params.filename, res)
|
||||
}
|
||||
)
|
||||
this.app.post(
|
||||
'/yesplaymusic/audio/:id',
|
||||
async (req: Request, res: Response) => {
|
||||
const id = Number(req.params.id)
|
||||
const { url } = req.query
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).send({ error: 'Invalid param id' })
|
||||
}
|
||||
if (!url) {
|
||||
return res.status(400).send({ error: 'Invalid query url' })
|
||||
}
|
||||
|
||||
if (
|
||||
!req.files ||
|
||||
Object.keys(req.files).length === 0 ||
|
||||
!req.files.file
|
||||
) {
|
||||
return res.status(400).send('No audio were uploaded.')
|
||||
}
|
||||
if ('length' in req.files.file) {
|
||||
return res.status(400).send('Only can upload one audio at a time.')
|
||||
}
|
||||
|
||||
try {
|
||||
await cache.setAudio(req.files.file.data, {
|
||||
id,
|
||||
url: String(req.query.url) || '',
|
||||
})
|
||||
res.status(200).send('Audio cached!')
|
||||
} catch (error) {
|
||||
res.status(500).send({ error })
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
listen() {
|
||||
this.app.listen(this.port, '0.0.0.0', () => {
|
||||
log.info(`[server] API server listening on port ${this.port}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default new Server()
|
||||
31
packages/desktop/main/store.ts
Normal file
31
packages/desktop/main/store.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import Store from 'electron-store'
|
||||
|
||||
export interface TypedElectronStore {
|
||||
window: {
|
||||
width: number
|
||||
height: number
|
||||
x?: number
|
||||
y?: number
|
||||
}
|
||||
// settings: State['settings']
|
||||
airplay: {
|
||||
credentials: {
|
||||
[key: string]: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const store = new Store<TypedElectronStore>({
|
||||
defaults: {
|
||||
window: {
|
||||
width: 1440,
|
||||
height: 1024,
|
||||
},
|
||||
// settings: initialState.settings,
|
||||
airplay: {
|
||||
credentials: {},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export default store
|
||||
173
packages/desktop/main/tray.ts
Normal file
173
packages/desktop/main/tray.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import path from 'path'
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
Menu,
|
||||
MenuItemConstructorOptions,
|
||||
nativeImage,
|
||||
Tray,
|
||||
} from 'electron'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { RepeatMode } from '@/shared/playerDataTypes'
|
||||
|
||||
const iconDirRoot =
|
||||
process.env.NODE_ENV === 'development'
|
||||
? path.join(process.cwd(), './src/main/assets/icons/tray')
|
||||
: path.join(__dirname, './assets/icons/tray')
|
||||
|
||||
enum MenuItemIDs {
|
||||
Play = 'play',
|
||||
Pause = 'pause',
|
||||
Like = 'like',
|
||||
Unlike = 'unlike',
|
||||
}
|
||||
|
||||
export interface YPMTray {
|
||||
setTooltip(text: string): void
|
||||
setLikeState(isLiked: boolean): void
|
||||
setPlayState(isPlaying: boolean): void
|
||||
setRepeatMode(mode: RepeatMode): void
|
||||
}
|
||||
|
||||
function createNativeImage(filename: string) {
|
||||
return nativeImage.createFromPath(path.join(iconDirRoot, filename))
|
||||
}
|
||||
|
||||
function createMenuTemplate(win: BrowserWindow): MenuItemConstructorOptions[] {
|
||||
const template: MenuItemConstructorOptions[] =
|
||||
process.platform === 'linux'
|
||||
? [
|
||||
{
|
||||
label: '显示主面板',
|
||||
click: () => win.show(),
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
return template.concat([
|
||||
{
|
||||
label: '播放',
|
||||
click: () => win.webContents.send(IpcChannels.Play),
|
||||
icon: createNativeImage('play.png'),
|
||||
id: MenuItemIDs.Play,
|
||||
},
|
||||
{
|
||||
label: '暂停',
|
||||
click: () => win.webContents.send(IpcChannels.Pause),
|
||||
icon: createNativeImage('pause.png'),
|
||||
id: MenuItemIDs.Pause,
|
||||
visible: false,
|
||||
},
|
||||
{
|
||||
label: '上一首',
|
||||
click: () => win.webContents.send(IpcChannels.Previous),
|
||||
icon: createNativeImage('left.png'),
|
||||
},
|
||||
{
|
||||
label: '下一首',
|
||||
click: () => win.webContents.send(IpcChannels.Next),
|
||||
icon: createNativeImage('right.png'),
|
||||
},
|
||||
{
|
||||
label: '循环模式',
|
||||
icon: createNativeImage('repeat.png'),
|
||||
submenu: [
|
||||
{
|
||||
label: '关闭循环',
|
||||
click: () => win.webContents.send(IpcChannels.Repeat, RepeatMode.Off),
|
||||
id: RepeatMode.Off,
|
||||
checked: true,
|
||||
type: 'radio',
|
||||
},
|
||||
{
|
||||
label: '列表循环',
|
||||
click: () => win.webContents.send(IpcChannels.Repeat, RepeatMode.On),
|
||||
id: RepeatMode.On,
|
||||
type: 'radio',
|
||||
},
|
||||
{
|
||||
label: '单曲循环',
|
||||
click: () => win.webContents.send(IpcChannels.Repeat, RepeatMode.One),
|
||||
id: RepeatMode.One,
|
||||
type: 'radio',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '加入喜欢',
|
||||
click: () => win.webContents.send(IpcChannels.Like),
|
||||
icon: createNativeImage('like.png'),
|
||||
id: MenuItemIDs.Like,
|
||||
},
|
||||
{
|
||||
label: '取消喜欢',
|
||||
click: () => win.webContents.send(IpcChannels.Like),
|
||||
icon: createNativeImage('unlike.png'),
|
||||
id: MenuItemIDs.Unlike,
|
||||
visible: false,
|
||||
},
|
||||
{
|
||||
label: '退出',
|
||||
click: () => app.exit(),
|
||||
icon: createNativeImage('exit.png'),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
class YPMTrayImpl implements YPMTray {
|
||||
private _win: BrowserWindow
|
||||
private _tray: Tray
|
||||
private _template: MenuItemConstructorOptions[]
|
||||
private _contextMenu: Menu
|
||||
|
||||
constructor(win: BrowserWindow) {
|
||||
this._win = win
|
||||
const icon = createNativeImage('menu@88.png').resize({
|
||||
height: 20,
|
||||
width: 20,
|
||||
})
|
||||
this._tray = new Tray(icon)
|
||||
this._template = createMenuTemplate(this._win)
|
||||
this._contextMenu = Menu.buildFromTemplate(this._template)
|
||||
|
||||
this._updateContextMenu()
|
||||
this.setTooltip('YesPlayMusic')
|
||||
|
||||
this._tray.on('click', () => win.show())
|
||||
}
|
||||
|
||||
private _updateContextMenu() {
|
||||
this._tray.setContextMenu(this._contextMenu)
|
||||
}
|
||||
|
||||
setTooltip(text: string) {
|
||||
this._tray.setToolTip(text)
|
||||
}
|
||||
|
||||
setLikeState(isLiked: boolean) {
|
||||
this._contextMenu.getMenuItemById(MenuItemIDs.Like)!.visible = !isLiked
|
||||
this._contextMenu.getMenuItemById(MenuItemIDs.Unlike)!.visible = isLiked
|
||||
this._updateContextMenu()
|
||||
}
|
||||
|
||||
setPlayState(isPlaying: boolean) {
|
||||
this._contextMenu.getMenuItemById(MenuItemIDs.Play)!.visible = !isPlaying
|
||||
this._contextMenu.getMenuItemById(MenuItemIDs.Pause)!.visible = isPlaying
|
||||
this._updateContextMenu()
|
||||
}
|
||||
|
||||
setRepeatMode(mode: RepeatMode) {
|
||||
const item = this._contextMenu.getMenuItemById(mode)
|
||||
if (item) {
|
||||
item.checked = true
|
||||
this._updateContextMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createTray(win: BrowserWindow): YPMTray {
|
||||
return new YPMTrayImpl(win)
|
||||
}
|
||||
40
packages/desktop/main/utils.ts
Normal file
40
packages/desktop/main/utils.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import pkg from '../../../package.json'
|
||||
|
||||
export const isDev = process.env.NODE_ENV === 'development'
|
||||
export const isProd = process.env.NODE_ENV === 'production'
|
||||
export const isWindows = process.platform === 'win32'
|
||||
export const isMac = process.platform === 'darwin'
|
||||
export const isLinux = process.platform === 'linux'
|
||||
export const dirname = isDev ? process.cwd() : __dirname
|
||||
export const devUserDataPath = path.resolve(process.cwd(), '../../tmp/userData')
|
||||
export const portableUserDataPath = path.resolve(
|
||||
process.env.PORTABLE_EXECUTABLE_DIR || '',
|
||||
'./YesPlayMusic-UserData'
|
||||
)
|
||||
export const logsPath = {
|
||||
linux: `~/.config/${pkg.productName}/logs`,
|
||||
darwin: `~/Library/Logs/${pkg.productName}/`,
|
||||
win32: `%USERPROFILE%\\AppData\\Roaming\\${pkg.productName}\\logs`,
|
||||
}[process.platform as 'darwin' | 'win32' | 'linux']
|
||||
|
||||
export const createDirIfNotExist = (dir: string) => {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
export const createFileIfNotExist = (file: string) => {
|
||||
createDirIfNotExist(path.dirname(file))
|
||||
if (!fs.existsSync(file)) {
|
||||
fs.writeFileSync(file, '')
|
||||
}
|
||||
}
|
||||
|
||||
export const getNetworkInfo = () => {
|
||||
return os.networkInterfaces().en0?.find(n => n.family === 'IPv4')
|
||||
}
|
||||
|
||||
export const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
|
||||
84
packages/desktop/main/windowsTaskbar.ts
Normal file
84
packages/desktop/main/windowsTaskbar.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { BrowserWindow, nativeImage, ThumbarButton } from 'electron'
|
||||
import path from 'path'
|
||||
|
||||
enum ItemKeys {
|
||||
Play = 'play',
|
||||
Pause = 'pause',
|
||||
Previous = 'previous',
|
||||
Next = 'next',
|
||||
}
|
||||
|
||||
type ThumbarButtonMap = Map<ItemKeys, ThumbarButton>
|
||||
|
||||
const iconDirRoot =
|
||||
process.env.NODE_ENV === 'development'
|
||||
? path.join(process.cwd(), './src/main/assets/icons/taskbar')
|
||||
: path.join(__dirname, './assets/icons/taskbar')
|
||||
|
||||
function createNativeImage(filename: string) {
|
||||
return nativeImage.createFromPath(path.join(iconDirRoot, filename))
|
||||
}
|
||||
|
||||
function createThumbarButtons(win: BrowserWindow): ThumbarButtonMap {
|
||||
return new Map<ItemKeys, ThumbarButton>()
|
||||
.set(ItemKeys.Play, {
|
||||
click: () => win.webContents.send(IpcChannels.Play),
|
||||
icon: createNativeImage('play.png'),
|
||||
tooltip: '播放',
|
||||
})
|
||||
.set(ItemKeys.Pause, {
|
||||
click: () => win.webContents.send(IpcChannels.Pause),
|
||||
icon: createNativeImage('pause.png'),
|
||||
tooltip: '暂停',
|
||||
})
|
||||
.set(ItemKeys.Previous, {
|
||||
click: () => win.webContents.send(IpcChannels.Previous),
|
||||
icon: createNativeImage('previous.png'),
|
||||
tooltip: '上一首',
|
||||
})
|
||||
.set(ItemKeys.Next, {
|
||||
click: () => win.webContents.send(IpcChannels.Next),
|
||||
icon: createNativeImage('next.png'),
|
||||
tooltip: '下一首',
|
||||
})
|
||||
}
|
||||
|
||||
export interface Thumbar {
|
||||
setPlayState(isPlaying: boolean): void
|
||||
}
|
||||
|
||||
class ThumbarImpl implements Thumbar {
|
||||
private _win: BrowserWindow
|
||||
private _buttons: ThumbarButtonMap
|
||||
|
||||
private _playOrPause: ThumbarButton
|
||||
private _previous: ThumbarButton
|
||||
private _next: ThumbarButton
|
||||
|
||||
constructor(win: BrowserWindow) {
|
||||
this._win = win
|
||||
this._buttons = createThumbarButtons(win)
|
||||
|
||||
this._playOrPause = this._buttons.get(ItemKeys.Play)!
|
||||
this._previous = this._buttons.get(ItemKeys.Previous)!
|
||||
this._next = this._buttons.get(ItemKeys.Next)!
|
||||
}
|
||||
|
||||
private _updateThumbarButtons(clear: boolean) {
|
||||
this._win.setThumbarButtons(
|
||||
clear ? [] : [this._previous, this._playOrPause, this._next]
|
||||
)
|
||||
}
|
||||
|
||||
setPlayState(isPlaying: boolean) {
|
||||
this._playOrPause = this._buttons.get(
|
||||
isPlaying ? ItemKeys.Pause : ItemKeys.Play
|
||||
)!
|
||||
this._updateThumbarButtons(false)
|
||||
}
|
||||
}
|
||||
|
||||
export function createTaskbar(win: BrowserWindow): Thumbar {
|
||||
return new ThumbarImpl(win)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue