feat: updates

This commit is contained in:
qier222 2023-01-24 16:29:33 +08:00
parent c6c59b2cd9
commit 7ce516877e
No known key found for this signature in database
63 changed files with 6591 additions and 1107 deletions

View file

@ -0,0 +1,31 @@
import fastify from 'fastify'
import fastifyStatic from '@fastify/static'
import path from 'path'
import fastifyCookie from '@fastify/cookie'
import { isProd } from '../env'
import log from '../log'
import netease from './routes/netease'
import appleMusic from './routes/r3play/appleMusic'
const server = fastify({
ignoreTrailingSlash: true,
})
server.register(fastifyCookie)
if (isProd) {
server.register(fastifyStatic, {
root: path.join(__dirname, '../web'),
})
}
server.register(netease, { prefix: '/netease' })
server.register(appleMusic)
const port = Number(
isProd
? process.env.ELECTRON_WEB_SERVER_PORT || 42710
: process.env.ELECTRON_DEV_NETEASE_API_PORT || 30001
)
server.listen({ port })
log.info(`[appServer] http server listening on port ${port}`)

View file

@ -0,0 +1,58 @@
import { APIs } from '@/shared/CacheAPIs'
import { pathCase, snakeCase } from 'change-case'
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'
import NeteaseCloudMusicApi from 'NeteaseCloudMusicApi'
import cache from '../../cache'
async function netease(fastify: FastifyInstance) {
const getHandler = (name: string, neteaseApi: (params: any) => any) => {
return async (
req: FastifyRequest<{ Querystring: { [key: string]: string } }>,
reply: FastifyReply
) => {
// Get track details from cache
if (name === APIs.Track) {
const cacheData = cache.get(name, req.query)
if (cacheData) {
return cache
}
}
// Request netease api
try {
const result = await neteaseApi({
...req.query,
cookie: req.cookies,
})
cache.set(name, result.body, req.query)
return reply.send(result.body)
} catch (error: any) {
if ([400, 301].includes(error.status)) {
return reply.status(error.status).send(error.body)
}
return reply.status(500)
}
}
}
// 循环注册NeteaseCloudMusicApi所有接口
Object.entries(NeteaseCloudMusicApi).forEach(([nameInSnakeCase, neteaseApi]: [string, any]) => {
// 例外
if (
['serveNcmApi', 'getModulesDefinitions', snakeCase(APIs.SongUrl)].includes(nameInSnakeCase)
) {
return
}
const name = pathCase(nameInSnakeCase)
const handler = getHandler(name, neteaseApi)
fastify.get(`/${name}`, handler)
fastify.post(`/${name}`, handler)
})
fastify.get('/', () => 'NeteaseCloudMusicApi')
}
export default netease

View file

@ -0,0 +1,12 @@
import { FastifyInstance } from 'fastify'
import proxy from '@fastify/http-proxy'
async function appleMusic(fastify: FastifyInstance) {
fastify.register(proxy, {
upstream: 'http://168.138.174.244:35530/',
prefix: '/r3play/apple-music',
rewritePrefix: '/apple-music',
})
}
export default appleMusic

View file

@ -1,90 +0,0 @@
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.eyJpc3MiOiJBTVBXZWJQbGF5IiwiaWF0IjoxNjYxNDQwNDMyLCJleHAiOjE2NzY5OTI0MzIsInJvb3RfaHR0cHNfb3JpZ2luIjpbImFwcGxlLmNvbSJdfQ.z4BMv9_O4MpMK2iFhYkDqPsx53soPSnlXXK3jm99pHqGOrZADvTgEUw2U7_B1W0MAtFiWBYhYcGvWrzaOig6Bw',
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',
Origin: 'https://music.apple.com',
}
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
with: 'serverBubbles',
},
}).catch(e => {
log.debug('[appleMusic] Search artist error', e)
})
const artist = searchResult?.data?.results?.artist?.data?.[0]
if (
artist &&
artist?.attributes?.name?.toLowerCase() === name.toLowerCase()
) {
return artist
}
}

View file

@ -147,9 +147,7 @@ class Cache {
break
}
case APIs.Track: {
const ids: number[] = params?.ids
.split(',')
.map((id: string) => Number(id))
const ids: number[] = params?.ids.split(',').map((id: string) => Number(id))
if (ids.length === 0) return
if (ids.includes(NaN)) return
@ -252,17 +250,6 @@ class Cache {
}
}
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' })
@ -281,10 +268,7 @@ 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 })
@ -301,8 +285,7 @@ class Cache {
}
const meta = await musicMetadata.parseBuffer(buffer)
const br =
meta?.format?.codec === 'OPUS' ? 165000 : meta.format.bitrate ?? 0
const br = meta?.format?.codec === 'OPUS' ? 165000 : meta.format.bitrate ?? 0
const type =
{
'MPEG 1 Layer 3': 'mp3',

View file

@ -77,10 +77,7 @@ const readSqlFile = (filename: string) => {
class DB {
sqlite: SQLite3.Database
dbFilePath: string = path.resolve(
app.getPath('userData'),
'./api_cache/db.sqlite'
)
dbFilePath: string = path.resolve(app.getPath('userData'), './api_cache/db.sqlite')
constructor() {
log.info('[db] Initializing database...')
@ -109,14 +106,8 @@ class DB {
`../../bin/better_sqlite3_${os.platform}_${os.arch}.node`
)
const prodBinPaths = {
darwin: path.resolve(
app.getPath('exe'),
`../../Resources/bin/better_sqlite3.node`
),
win32: path.resolve(
app.getPath('exe'),
`../resources/bin/better_sqlite3.node`
),
darwin: path.resolve(app.getPath('exe'), `../../Resources/bin/better_sqlite3.node`),
win32: path.resolve(app.getPath('exe'), `../resources/bin/better_sqlite3.node`),
linux: '',
}
return isProd
@ -128,6 +119,7 @@ class DB {
log.info('[db] Initializing database tables...')
const init = readSqlFile('init.sql')
this.sqlite.exec(init)
this.sqlite.pragma('journal_mode=WAL')
log.info('[db] Database tables initialized.')
}
@ -167,9 +159,7 @@ class DB {
table: T,
key: TablesStructures[T]['id']
): TablesStructures[T] | undefined {
return this.sqlite
.prepare(`SELECT * FROM ${table} WHERE id = ? LIMIT 1`)
.get(key)
return this.sqlite.prepare(`SELECT * FROM ${table} WHERE id = ? LIMIT 1`).get(key)
}
findMany<T extends TableNames>(
@ -184,11 +174,7 @@ class DB {
return this.sqlite.prepare(`SELECT * FROM ${table}`).all()
}
create<T extends TableNames>(
table: T,
data: TablesStructures[T],
skipWhenExist: boolean = true
) {
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)
}
@ -202,9 +188,7 @@ class DB {
.map(key => `:${key}`)
.join(', ')
const insert = this.sqlite.prepare(
`INSERT ${
skipWhenExist ? 'OR IGNORE' : ''
} INTO ${table} VALUES (${valuesQuery})`
`INSERT ${skipWhenExist ? 'OR IGNORE' : ''} INTO ${table} VALUES (${valuesQuery})`
)
const insertMany = this.sqlite.transaction((rows: any[]) => {
rows.forEach((row: any) => insert.run(row))
@ -216,18 +200,14 @@ class DB {
const valuesQuery = Object.keys(data)
.map(key => `:${key}`)
.join(', ')
return this.sqlite
.prepare(`INSERT OR REPLACE INTO ${table} VALUES (${valuesQuery})`)
.run(data)
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 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))
})
@ -238,10 +218,7 @@ class DB {
return this.sqlite.prepare(`DELETE FROM ${table} WHERE id = ?`).run(key)
}
deleteMany<T extends TableNames>(
table: T,
keys: TablesStructures[T]['id'][]
) {
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()
}

View file

@ -3,4 +3,4 @@ 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 appName = 'R3Play'
export const appName = 'R3PLAY'

View file

@ -1,6 +1,6 @@
import './preload' // must be first
import './sentry'
import './server'
// import './server'
import { BrowserWindow, BrowserWindowConstructorOptions, app, shell } from 'electron'
import { release } from 'os'
import { join } from 'path'
@ -12,8 +12,7 @@ import { createTaskbar, Thumbar } from './windowsTaskbar'
import { createMenu } from './menu'
import { isDev, isWindows, isLinux, isMac, appName } from './env'
import store from './store'
// import './surrealdb'
// import Airplay from './airplay'
import './appServer/appServer'
class Main {
win: BrowserWindow | null = null
@ -83,10 +82,12 @@ class Main {
width: store.get('window.width'),
height: store.get('window.height'),
minWidth: 1240,
minHeight: 848,
minHeight: 800,
titleBarStyle: isMac ? 'customButtonsOnHover' : 'hidden',
trafficLightPosition: { x: 24, y: 24 },
frame: false,
fullscreenable: true,
resizable: true,
transparent: true,
backgroundColor: 'rgba(0, 0, 0, 0)',
show: false,

View file

@ -12,7 +12,6 @@ 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,
@ -23,10 +22,7 @@ const on = <T extends keyof IpcChannelsParams>(
const handle = <T extends keyof IpcChannelsParams>(
channel: T,
listener: (
event: Electron.IpcMainInvokeEvent,
params: IpcChannelsParams[T]
) => void
listener: (event: Electron.IpcMainInvokeEvent, params: IpcChannelsParams[T]) => void
) => {
return ipcMain.handle(channel, listener)
}
@ -162,49 +158,46 @@ function initOtherIpcMain() {
*
*/
on(IpcChannels.GetAudioCacheSize, event => {
fastFolderSize(
path.join(app.getPath('userData'), './audio_cache'),
(error, bytes) => {
if (error) throw error
fastFolderSize(path.join(app.getPath('userData'), './audio_cache'), (error, bytes) => {
if (error) throw error
event.returnValue = prettyBytes(bytes ?? 0)
}
)
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
}
// 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
}
)
// 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获取歌手信息
// **/
// 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
})
// /**
// * 从缓存读取Apple Music歌手信息
// */
// on(IpcChannels.GetArtistFromAppleMusic, (event, { id }) => {
// const artist = cache.get(APIs.AppleMusicArtist, id)
// event.returnValue = artist === 'no' ? undefined : artist
// })
/**
* 退

View file

@ -14,6 +14,7 @@ import { appName, isProd } from './env'
import { APIs } from '@/shared/CacheAPIs'
import history from 'connect-history-api-fallback'
import { db, Tables } from './db'
import axios from 'axios'
class Server {
port = Number(
@ -30,12 +31,32 @@ class Server {
this.app.use(cookieParser())
this.app.use(fileUpload())
this.getAudioUrlHandler()
this.r3playApiHandler()
this.neteaseHandler()
this.cacheAudioHandler()
this.serveStaticForProduction()
this.listen()
}
r3playApiHandler() {
this.app.get(`/r3play/apple-music/:endpoint`, async (req: Request, res: Response) => {
log.debug(`[server] Handling request: ${req.path}`)
const { endpoint } = req.params
const { query } = req
axios
.get(`https://r3play.app/r3play/apple-music/${endpoint}`, {
params: query,
})
.then(response => {
console.log(response.data)
res.send(response.data)
})
.catch(error => {
console.log(error)
})
})
}
neteaseHandler() {
Object.entries(this.netease).forEach(([name, handler]: [string, any]) => {
// 例外处理

View file

@ -1,6 +1,6 @@
{
"name": "desktop",
"productName": "R3Play",
"productName": "R3PLAY",
"private": true,
"version": "2.0.0",
"main": "./main/index.js",
@ -20,8 +20,10 @@
"node": "^14.13.1 || >=16.0.0"
},
"dependencies": {
"@fastify/cookie": "^8.3.0",
"@fastify/http-proxy": "^8.4.0",
"@sentry/electron": "^3.0.7",
"NeteaseCloudMusicApi": "^4.8.4",
"NeteaseCloudMusicApi": "^4.8.7",
"better-sqlite3": "8.0.1",
"change-case": "^4.1.2",
"compare-versions": "^4.1.3",
@ -57,9 +59,9 @@
"ora": "^6.1.2",
"picocolors": "^1.0.0",
"prettier": "*",
"tsx": "*",
"typescript": "*",
"vitest": "^0.20.3",
"wait-on": "^7.0.1",
"tsx": "*"
"wait-on": "^7.0.1"
}
}

View file

@ -32,13 +32,10 @@ const options = {
define: envForEsbuild,
minify: true,
external: [
...builtinModules.filter(
x => !/^_|^(internal|v8|node-inspect)\/|\//.test(x)
),
...builtinModules.filter(x => !/^_|^(internal|v8|node-inspect)\/|\//.test(x)),
'electron',
'NeteaseCloudMusicApi',
'better-sqlite3',
// '@unblockneteasemusic/rust-napi',
],
}
@ -55,9 +52,7 @@ const runApp = () => {
if (argv.watch) {
waitOn(
{
resources: [
`http://127.0.0.1:${process.env.ELECTRON_WEB_SERVER_PORT}/index.html`,
],
resources: [`http://127.0.0.1:${process.env.ELECTRON_WEB_SERVER_PORT}/index.html`],
timeout: 5000,
},
err => {
@ -101,11 +96,7 @@ if (argv.watch) {
console.log(TAG, pc.green('Main Process Build Succeeded.'))
})
.catch(error => {
console.log(
`\n${TAG} ${pc.red('Main Process Build Failed')}\n`,
error,
'\n'
)
console.log(`\n${TAG} ${pc.red('Main Process Build Failed')}\n`, error, '\n')
})
.finally(() => {
spinner.stop()

View file

@ -0,0 +1,5 @@
APPLE_MUSIC_TOKEN = Bearer xxxxxxxxxxxxxxxxxxxxxx
# If you want to use a different database provider like SQLite/PostgreSQL,
# you will need to change the 'provider' in /prisma/schema.prisma
DATABASE_URL='mysql://USER:PASSWORD@HOST:PORT/DATABASE'

View file

@ -1,4 +0,0 @@
test-env: [
TS_NODE_FILES=true,
TS_NODE_PROJECT=./test/tsconfig.json
]

View file

@ -1,41 +0,0 @@
# fly.toml file generated for ypm on 2022-09-06T00:57:21+08:00
app = "ypm"
kill_signal = "SIGINT"
kill_timeout = 5
processes = ["npm run start"]
[build]
builder = "heroku/buildpacks:20"
[env]
PORT = "8080"
[experimental]
allowed_public_ports = []
auto_rollback = true
[[services]]
http_checks = []
internal_port = 35530
processes = ["app"]
protocol = "tcp"
script_checks = []
[services.concurrency]
hard_limit = 25
soft_limit = 20
type = "connections"
[[services.ports]]
handlers = ["http"]
port = 80
[[services.ports]]
handlers = ["tls", "http"]
port = 443
[[services.tcp_checks]]
grace_period = "1s"
interval = "15s"
restart_limit = 0
timeout = "2s"

5685
packages/server/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -3,15 +3,11 @@
"version": "1.0.0",
"description": "This project was bootstrapped with Fastify-CLI.",
"main": "app.ts",
"directories": {
"test": "test"
},
"scripts": {
"test": "npm run build:ts && tsc -p test/tsconfig.json && tap --ts \"test/**/*.test.ts\"",
"start": "fastify start --port 35530 --address 0.0.0.0 -l info dist/app.js",
"build:ts": "tsc",
"watch:ts": "tsc -w",
"dev": "npm run build:ts && concurrently -k -p \"[{name}]\" -n \"TypeScript,App\" -c \"yellow.bold,cyan.bold\" \"npm:watch:ts\" \"npm:dev:start\"",
"build": "tsc",
"watch": "tsc -w",
"dev": "npm run build && concurrently -k -p \"[{name}]\" -n \"TypeScript,App\" -c \"yellow.bold,cyan.bold\" \"npm:watch\" \"npm:dev:start\"",
"dev:start": "fastify start --ignore-watch=.ts$ -w --port 35530 --address 0.0.0.0 -l info -P dist/app.js"
},
"keywords": [],
@ -20,18 +16,20 @@
"dependencies": {
"@fastify/autoload": "^5.0.0",
"@fastify/sensible": "^4.1.0",
"@fastify/static": "^6.6.1",
"@prisma/client": "^4.8.1",
"NeteaseCloudMusicApi": "^4.8.7",
"axios": "^0.27.2",
"fastify": "^4.0.0",
"fastify": "^4.5.3",
"fastify-cli": "^4.4.0",
"fastify-plugin": "^3.0.0"
},
"devDependencies": {
"@types/node": "^18.0.0",
"@types/tap": "^15.0.5",
"concurrently": "^7.0.0",
"fastify-tsconfig": "^1.0.1",
"tap": "^16.1.0",
"ts-node": "^10.4.0",
"typescript": "^4.5.4"
"prisma": "^4.8.1",
"ts-node": "^10.9.1",
"typescript": "^4.9.4"
}
}

View file

@ -0,0 +1,45 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
relationMode = "prisma"
}
model Album {
id Int @id @unique
neteaseId Int @unique
name String @db.Text
neteaseName String @db.Text
artistName String @db.Text
neteaseArtistName String @db.Text
copyright String? @db.Text
editorialVideo String? @db.Text
artwork String? @db.Text
editorialNote AlbumEditorialNote?
}
model AlbumEditorialNote {
id Int @id @unique
album Album @relation(fields: [id], references: [id])
en_US String? @map("en-US") @db.Text
zh_CN String? @map("zh-CN") @db.Text
}
model Artist {
id Int @id @unique
neteaseId Int @unique
name String @db.Text
artwork String? @db.Text
editorialVideo String? @db.Text
artistBio ArtistBio?
}
model ArtistBio {
id Int @id @unique
artist Artist @relation(fields: [id], references: [id])
en_US String? @map("en-US") @db.Text
zh_CN String? @map("zh-CN") @db.Text
}

View file

@ -2,28 +2,12 @@ import { join } from 'path'
import AutoLoad, { AutoloadPluginOptions } from '@fastify/autoload'
import { FastifyPluginAsync } from 'fastify'
export type AppOptions = {
// Place your custom options for app below here.
} & Partial<AutoloadPluginOptions>
const app: FastifyPluginAsync<AppOptions> = async (
fastify,
opts
): Promise<void> => {
// Place here your custom code!
// Do not touch the following lines
// This loads all plugins defined in plugins
// those should be support plugins that are reused
// through your application
const app: FastifyPluginAsync<AutoloadPluginOptions> = async (fastify, opts) => {
void fastify.register(AutoLoad, {
dir: join(__dirname, 'plugins'),
options: opts,
})
// This loads all plugins defined in routes
// define your routes in one of these
void fastify.register(AutoLoad, {
dir: join(__dirname, 'routes'),
options: opts,

View file

@ -0,0 +1,25 @@
import fp from 'fastify-plugin'
import { FastifyPluginAsync } from 'fastify'
import { PrismaClient } from '@prisma/client'
// Use TypeScript module augmentation to declare the type of server.prisma to be PrismaClient
declare module 'fastify' {
interface FastifyInstance {
prisma: PrismaClient
}
}
const prismaPlugin: FastifyPluginAsync = fp(async (server, options) => {
const prisma = new PrismaClient()
await prisma.$connect()
// Make Prisma Client available through the fastify server instance: server.prisma
server.decorate('prisma', prisma)
server.addHook('onClose', async server => {
await server.prisma.$disconnect()
})
})
export default prismaPlugin

View file

@ -1,24 +1,65 @@
import { FastifyPluginAsync } from 'fastify'
import appleMusicRequest from '../../utils/appleMusicRequest'
import { album as getAlbum } from 'NeteaseCloudMusicApi'
const example: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
type ResponseSchema = {
id: number
neteaseId: number
name: string
artistName: string
editorialVideo: string
artwork: string
copyright: string
neteaseName: string
neteaseArtistName: string
editorialNote: {
en_US: string
zh_CN: string
}
}
const album: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
fastify.get<{
Querystring: {
name: string
artist: string
lang: 'zh-CN' | 'en-US'
neteaseId: string
lang?: 'zh-CN' | 'en-US'
}
}>('/album', async function (request, reply) {
const { name, lang, artist } = request.query
}>('/album', opts, async function (request, reply): Promise<ResponseSchema | undefined> {
const { neteaseId: neteaseIdString, lang = 'en-US' } = request.query
// validate neteaseAlbumID
const neteaseId = Number(neteaseIdString)
if (isNaN(neteaseId)) {
reply.code(400).send('params "neteaseId" is required')
return
}
// get from database
const fromDB = await fastify.prisma.album.findFirst({
where: { neteaseId: neteaseId },
include: { editorialNote: { select: { en_US: true, zh_CN: true } } },
})
if (fromDB) {
return fromDB as ResponseSchema
}
// get from netease
const { body: neteaseAlbum } = (await getAlbum({ id: neteaseId })) as any
const artist = neteaseAlbum?.album?.artist?.name
const albumName = neteaseAlbum?.album?.name
if (!artist || !albumName) {
return
}
// get from apple
const fromApple = await appleMusicRequest({
method: 'GET',
url: '/search',
params: {
term: name,
term: `${artist} ${albumName}`,
types: 'albums',
'fields[albums]': 'artistName,name,editorialVideo,editorialNotes',
limit: '1',
'fields[albums]': 'artistName,artwork,name,copyright,editorialVideo,editorialNotes',
limit: '10',
l: lang.toLowerCase(),
},
})
@ -27,12 +68,59 @@ const example: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
const album =
albums?.find(
(a: any) =>
a.attributes.name.toLowerCase() === name.toLowerCase() &&
a.attributes.name.toLowerCase() === albumName.toLowerCase() &&
a.attributes.artistName.toLowerCase() === artist.toLowerCase()
) || albums?.[0]
if (!album) return
return album
// get editorialNote
const editorialNote = {
en_US: lang === 'en-US' ? album.attributes.editorialNotes?.standard : '',
zh_CN: lang === 'zh-CN' ? album.attributes.editorialNotes?.standard : '',
}
const otherLangEditorialNoteResult = await appleMusicRequest({
method: 'GET',
url: `/albums/${album.id}`,
params: {
'fields[albums]': 'editorialNotes',
'omit[resource:albums]': 'relationships',
l: lang === 'zh-CN' ? 'en-US' : 'zh-CN',
},
})
const otherLangEditorialNote =
otherLangEditorialNoteResult?.data?.[0]?.attributes?.editorialNotes?.standard
if (lang === 'zh-CN') {
editorialNote.en_US = otherLangEditorialNote
} else if (lang === 'en-US') {
editorialNote.zh_CN = otherLangEditorialNote
}
const data: ResponseSchema = {
id: Number(album.id),
neteaseId: Number(neteaseId),
name: album.attributes.name,
artistName: album.attributes.artistName,
editorialVideo: album.attributes.editorialVideo?.motionDetailSquare?.video,
artwork: album.attributes.artwork?.url,
editorialNote,
copyright: album.attributes.copyright,
neteaseName: albumName,
neteaseArtistName: artist,
}
reply.send(data)
// save to database
await fastify.prisma.album
.create({
data: {
...data,
editorialNote: { create: editorialNote },
},
})
.catch(e => console.error(e))
return
})
}
export default example
export default album

View file

@ -1,46 +1,112 @@
import { FastifyPluginAsync } from 'fastify'
import appleMusicRequest from '../../utils/appleMusicRequest'
import { artist_detail as getArtistDetail } from 'NeteaseCloudMusicApi'
const example: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
type ResponseSchema = {
id: number
neteaseId: number
editorialVideo: string
artwork: string
name: string
artistBio: {
en_US: string
zh_CN: string
}
}
const artist: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
fastify.get<{
Querystring: {
name: string
lang: 'zh-CN' | 'en-US'
neteaseId: string
lang?: 'zh-CN' | 'en-US'
}
}>('/artist', async function (request, reply) {
const { name, lang } = request.query
}>('/artist', async function (request, reply): Promise<ResponseSchema | undefined> {
const { neteaseId: neteaseIdString, lang = 'en-US' } = request.query
if (!name) {
return {
code: 400,
message: 'params "name" is required',
}
// validate neteaseId
const neteaseId = Number(neteaseIdString)
if (isNaN(neteaseId)) {
reply.code(400).send('params "neteaseId" is required')
return
}
// get from database
const fromDB = await fastify.prisma.artist.findFirst({
where: { neteaseId: neteaseId },
include: { artistBio: { select: { en_US: true, zh_CN: true } } },
})
if (fromDB) {
return fromDB as ResponseSchema
}
// get from netease
const { body: neteaseArtist } = (await getArtistDetail({ id: neteaseId })) as any
const artistName = neteaseArtist?.data?.artist?.name
if (!artistName) {
return
}
const fromApple = await appleMusicRequest({
method: 'GET',
url: '/search',
params: {
term: name,
term: artistName,
types: 'artists',
'fields[artists]': 'url,name,artwork,editorialVideo,artistBio',
'omit[resource:artists]': 'relationships',
platform: 'web',
limit: '1',
l: lang?.toLowerCase() || 'en-us',
with: 'serverBubbles',
limit: '5',
l: lang?.toLowerCase(),
},
})
const artist = fromApple?.results?.artist?.data?.[0]
if (
artist &&
artist?.attributes?.name?.toLowerCase() === name.toLowerCase()
) {
return artist
if (artist?.attributes?.name?.toLowerCase() !== artistName.toLowerCase()) {
return
}
// get ArtistBio
const artistBio = {
en_US: lang === 'en-US' ? artist.attributes.artistBio : '',
zh_CN: lang === 'zh-CN' ? artist.attributes.artistBio : '',
}
const otherLangArtistBioResult = await appleMusicRequest({
method: 'GET',
url: `/artists/${artist.id}`,
params: {
'fields[artists]': 'artistBio',
'omit[resource:artists]': 'relationships',
l: lang === 'zh-CN' ? 'en-US' : 'zh-CN',
},
})
const otherLangArtistBio = otherLangArtistBioResult?.data?.[0]?.attributes?.artistBio
if (lang === 'zh-CN') {
artistBio.en_US = otherLangArtistBio
} else if (lang === 'en-US') {
artistBio.zh_CN = otherLangArtistBio
}
const data: ResponseSchema = {
id: Number(artist.id),
neteaseId: neteaseId,
name: artist.attributes.name,
artistBio,
editorialVideo: artist?.attributes.editorialVideo?.motionArtistSquare1x1?.video,
artwork: artist?.attributes?.artwork?.url,
}
reply.send(data)
// save to database
await fastify.prisma.artist
.create({
data: {
...data,
artistBio: { create: artistBio },
},
})
.catch(e => console.error(e))
})
}
export default example
export default artist

View file

@ -1,8 +1,8 @@
import { FastifyPluginAsync } from 'fastify'
const root: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
fastify.get('/', async function (request, reply) {
return { root: true }
fastify.get('/', async (request, reply) => {
return 'R3PLAY server is running!'
})
}

View file

@ -1,32 +0,0 @@
// This file contains code that we reuse between our tests.
import Fastify from 'fastify'
import fp from 'fastify-plugin'
import App from '../src/app'
import * as tap from 'tap'
export type Test = typeof tap['Test']['prototype']
// Fill in this config with all the configurations
// needed for testing the application
async function config() {
return {}
}
// Automatically build and tear down our instance
async function build(t: Test) {
const app = Fastify()
// fastify-plugin ensures that all decorators
// are exposed for testing purposes, this is
// different from the production setup
void app.register(fp(App), await config())
await app.ready()
// Tear down our app after we are done
t.teardown(() => void app.close())
return app
}
export { config, build }

View file

@ -1,11 +0,0 @@
import { test } from 'tap'
import Fastify from 'fastify'
import Support from '../../src/plugins/support'
test('support works standalone', async t => {
const fastify = Fastify()
void fastify.register(Support)
await fastify.ready()
t.equal(fastify.someSupport(), 'hugs')
})

View file

@ -1,12 +0,0 @@
import { test } from 'tap'
import { build } from '../helper'
test('example is loaded', async t => {
const app = await build(t)
const res = await app.inject({
url: '/example',
})
t.equal(res.payload, 'this is an example')
})

View file

@ -1,11 +0,0 @@
import { test } from 'tap'
import { build } from '../helper'
test('default root route', async t => {
const app = await build(t)
const res = await app.inject({
url: '/',
})
t.same(JSON.parse(res.payload), { root: true })
})

View file

@ -1,8 +0,0 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"noEmit": true
},
"include": ["../src/**/*.ts", "**/*.ts"]
}

View file

@ -0,0 +1,32 @@
interface FetchAppleMusicAlbumParams {
neteaseId: number | string
}
interface FetchAppleMusicAlbumResponse {
id: number
neteaseId: number
name: string
artistName: string
editorialVideo: string
artwork: string
editorialNote: {
en_US: string
zh_CN: string
}
}
interface FetchAppleMusicArtistParams {
neteaseId: number | string
}
interface FetchAppleMusicArtistResponse {
id: number
neteaseId: number
editorialVideo: string
artwork: string
name: string
artistBio: {
en_US: string
zh_CN: string
}
}

View file

@ -20,7 +20,7 @@ export interface FetchTracksResponse {
export interface FetchAudioSourceParams {
id: number
br?: number // bitrate, default 999000320000 = 320kbps
level?: 'standard' | 'higher' | 'exhigh' | 'lossless' | 'hires' // 128kbps 192kbps 320kbps Lossless Hi-Res
}
export interface FetchAudioSourceResponse {
code: number

1
packages/web/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.vercel

View file

@ -8,11 +8,12 @@ import ScrollRestoration from '@/web/components/ScrollRestoration'
import Toaster from './components/Toaster'
const App = () => {
const isMobile = useIsMobile()
// const isMobile = useIsMobile()
return (
<ErrorBoundary>
{isMobile ? <LayoutMobile /> : <Layout />}
{/* {isMobile ? <LayoutMobile /> : <Layout />} */}
<Layout />
<Toaster />
<ScrollRestoration />
<IpcRendererReact />

View file

@ -0,0 +1,25 @@
import request from '../utils/request'
// AppleMusic专辑
export function fetchAppleMusicAlbum(
params: FetchAppleMusicAlbumParams
): Promise<FetchAppleMusicAlbumResponse> {
return request({
url: '/r3play/apple-music/album',
method: 'get',
params,
baseURL: '/',
})
}
// AppleMusic艺人
export function fetchAppleMusicArtist(
params: FetchAppleMusicArtistParams
): Promise<FetchAppleMusicArtistResponse> {
return request({
url: '/r3play/apple-music/artist',
method: 'get',
params,
baseURL: '/',
})
}

View file

@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query'
import { fetchAppleMusicAlbum } from '../appleMusic'
const useAppleMusicAlbum = (id: string | number) => {
return useQuery(
['useAppleMusicAlbum', id],
async () => {
if (!id) return
return fetchAppleMusicAlbum({ neteaseId: id })
},
{
enabled: !!id,
refetchOnWindowFocus: false,
refetchInterval: false,
}
)
}
export default useAppleMusicAlbum

View file

@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query'
import { fetchAppleMusicArtist } from '../appleMusic'
const useAppleMusicArtist = (id: string | number) => {
return useQuery(
['useAppleMusicArtist', id],
async () => {
if (!id) return
return fetchAppleMusicArtist({ neteaseId: id })
},
{
enabled: !!id,
refetchOnWindowFocus: false,
refetchInterval: false,
}
)
}
export default useAppleMusicArtist

View file

@ -11,9 +11,7 @@ import {
} from '@/shared/api/Track'
// 获取歌曲详情
export function fetchTracks(
params: FetchTracksParams
): Promise<FetchTracksResponse> {
export function fetchTracks(params: FetchTracksParams): Promise<FetchTracksResponse> {
return request({
url: '/song/detail',
method: 'get',
@ -28,16 +26,18 @@ export function fetchAudioSource(
params: FetchAudioSourceParams
): Promise<FetchAudioSourceResponse> {
return request({
url: '/song/url',
url: '/song/url/v1',
method: 'get',
params,
params: {
level: 'exhigh',
...params,
timestamp: Date.now(),
},
})
}
// 获取歌词
export function fetchLyric(
params: FetchLyricParams
): Promise<FetchLyricResponse> {
export function fetchLyric(params: FetchLyricParams): Promise<FetchLyricResponse> {
return request({
url: '/lyric',
method: 'get',
@ -46,9 +46,7 @@ export function fetchLyric(
}
// 收藏歌曲
export function likeATrack(
params: LikeATrackParams
): Promise<LikeATrackResponse> {
export function likeATrack(params: LikeATrackParams): Promise<LikeATrackResponse> {
return request({
url: '/like',
method: 'post',

View file

@ -28,12 +28,7 @@ const ArtistInline = ({
if (!artists) return <div></div>
return (
<div
className={cx(
!className?.includes('line-clamp') && 'line-clamp-1',
className
)}
>
<div className={cx(!className?.includes('line-clamp') && 'line-clamp-1', className)}>
{artists.map((artist, index) => (
<span key={`${artist.id}-${artist.name}`}>
<span

View file

@ -6,7 +6,7 @@ import { prefetchAlbum } from '@/web/api/hooks/useAlbum'
import { prefetchPlaylist } from '@/web/api/hooks/usePlaylist'
import { memo, useCallback } from 'react'
import dayjs from 'dayjs'
import ArtistInline from './ArtistsInLine'
import ArtistInline from './ArtistsInline'
type ItemTitle = undefined | 'name'
type ItemSubTitle = undefined | 'artist' | 'year'
@ -56,15 +56,9 @@ const Album = ({
onMouseOver={prefetch}
/>
{title && (
<div className='line-clamp-2 mt-2 text-14 font-medium text-neutral-300'>
{title}
</div>
)}
{subtitle && (
<div className='mt-1 text-14 font-medium text-neutral-700'>
{subtitle}
</div>
<div className='line-clamp-2 mt-2 text-14 font-medium text-neutral-300'>{title}</div>
)}
{subtitle && <div className='mt-1 text-14 font-medium text-neutral-700'>{subtitle}</div>}
</div>
)
}
@ -107,21 +101,12 @@ const CoverRow = ({
return (
<div className={className}>
{/* Title */}
{title && (
<h4 className='mb-6 text-14 font-bold uppercase dark:text-neutral-300'>
{title}
</h4>
)}
{title && <h4 className='mb-6 text-14 font-bold uppercase dark:text-neutral-300'>{title}</h4>}
{/* Items */}
<div className='grid grid-cols-3 gap-4 lg:gap-6 xl:grid-cols-4 2xl:grid-cols-5'>
{albums?.map(album => (
<Album
key={album.id}
album={album}
itemTitle={itemTitle}
itemSubtitle={itemSubtitle}
/>
<Album key={album.id} album={album} itemTitle={itemTitle} itemSubtitle={itemSubtitle} />
))}
{playlists?.map(playlist => (
<Playlist key={playlist.id} playlist={playlist} />

View file

@ -2,7 +2,7 @@ import Main from '@/web/components/Main'
import Player from '@/web/components/Player'
import MenuBar from '@/web/components/MenuBar'
import Topbar from '@/web/components/Topbar/TopbarDesktop'
import { cx } from '@emotion/css'
import { css, cx } from '@emotion/css'
import player from '@/web/states/player'
import { useSnapshot } from 'valtio'
import Login from './Login'
@ -22,7 +22,10 @@ const Layout = () => {
id='layout'
className={cx(
'relative grid h-screen select-none overflow-hidden bg-white dark:bg-black',
window.env?.isElectron && !fullscreen && 'rounded-24'
window.env?.isElectron && !fullscreen && 'rounded-24',
css`
min-width: 720px;
`
)}
>
<BlurBackground />
@ -40,9 +43,7 @@ const Layout = () => {
{(window.env?.isWindows ||
window.env?.isLinux ||
window.localStorage.getItem('showWindowsTitleBar') === 'true') && (
<TitleBar />
)}
window.localStorage.getItem('showWindowsTitleBar') === 'true') && <TitleBar />}
<ContextMenus />

View file

@ -23,25 +23,19 @@ const LayoutMobile = () => {
<Router />
</main>
<div
className={cx(
'fixed bottom-0 left-0 right-0 z-20 pt-3 dark:bg-black',
css`
padding-bottom: calc(
className={cx('fixed bottom-0 left-0 right-0 z-20 pt-3 dark:bg-black')}
style={{
paddingBottom: `calc(
${isIosPwa ? '24px' : 'env(safe-area-inset-bottom)'} + 0.75rem
);
`
)}
)`,
}}
>
{showPlayer && (
<div
className={cx(
'absolute left-7 right-7 z-20',
css`
top: calc(
-100% - 6px + ${isIosPwa ? '24px' : 'env(safe-area-inset-bottom)'}
);
`
)}
className={cx('absolute left-7 right-7 z-20')}
style={{
top: `calc(-100% - 6px + ${isIosPwa ? '24px' : 'env(safe-area-inset-bottom)'})`,
}}
>
<Player />
</div>

View file

@ -11,13 +11,7 @@ import persistedUiStates from '@/web/states/persistedUiStates'
import useUser from '@/web/api/hooks/useUser'
import { useTranslation } from 'react-i18next'
const OR = ({
children,
onClick,
}: {
children: React.ReactNode
onClick: () => void
}) => {
const OR = ({ children, onClick }: { children: React.ReactNode; onClick: () => void }) => {
const { t } = useTranslation()
return (
@ -125,10 +119,9 @@ const Login = () => {
<motion.div
animate={animateCard}
className={cx(
'relative rounded-48 bg-white/10 p-9',
'relative h-fit rounded-48 bg-white/10 p-9',
css`
width: 392px;
height: fit-content;
`
)}
>
@ -136,9 +129,7 @@ const Login = () => {
{cardType === 'phone/email' && <LoginWithPhoneOrEmail />}
<OR onClick={handleSwitchCard}>
{cardType === 'qrCode'
? t`auth.use-phone-or-email`
: t`auth.scan-qr-code`}
{cardType === 'qrCode' ? t`auth.use-phone-or-email` : t`auth.scan-qr-code`}
</OR>
</motion.div>
</AnimatePresence>

View file

@ -1,6 +1,4 @@
import useUserLikedTracksIDs, {
useMutationLikeATrack,
} from '@/web/api/hooks/useUserLikedTracksIDs'
import useUserLikedTracksIDs, { useMutationLikeATrack } from '@/web/api/hooks/useUserLikedTracksIDs'
import player from '@/web/states/player'
import { resizeImage } from '@/web/utils/common'
@ -30,8 +28,7 @@ const PlayingTrack = () => {
[playerSnapshot.trackListSource]
)
const hasListSource =
playerSnapshot.mode !== PlayerMode.FM && trackListSource?.type
const hasListSource = playerSnapshot.mode !== PlayerMode.FM && trackListSource?.type
const toTrackListSource = () => {
if (!hasListSource) return
@ -76,16 +73,10 @@ const LikeButton = ({ track }: { track: Track | undefined | null }) => {
return (
<div className='mr-1 '>
<IconButton
onClick={() => track?.id && mutationLikeATrack.mutate(track.id)}
>
<IconButton onClick={() => track?.id && mutationLikeATrack.mutate(track.id)}>
<Icon
className='h-6 w-6 text-white'
name={
track?.id && userLikedSongs?.ids?.includes(track.id)
? 'heart'
: 'heart-outline'
}
name={track?.id && userLikedSongs?.ids?.includes(track.id) ? 'heart' : 'heart-outline'}
/>
</IconButton>
</div>
@ -101,10 +92,7 @@ const Controls = () => {
return (
<div className='flex items-center justify-center gap-2 text-white'>
{mode === PlayerMode.TrackList && (
<IconButton
onClick={() => track && player.prevTrack()}
disabled={!track}
>
<IconButton onClick={() => track && player.prevTrack()} disabled={!track}>
<Icon className='h-6 w-6' name='previous' />
</IconButton>
)}
@ -120,11 +108,7 @@ const Controls = () => {
>
<Icon
className='h-7 w-7'
name={
[PlayerState.Playing, PlayerState.Loading].includes(state)
? 'pause'
: 'play'
}
name={[PlayerState.Playing, PlayerState.Loading].includes(state) ? 'pause' : 'play'}
/>
</IconButton>
<IconButton onClick={() => track && player.nextTrack()} disabled={!track}>

View file

@ -2,7 +2,7 @@ import { css, cx } from '@emotion/css'
import player from '@/web/states/player'
import { useSnapshot } from 'valtio'
import { AnimatePresence, motion } from 'framer-motion'
import ArtistInline from '@/web/components/ArtistsInline'
import ArtistInline from '../ArtistsInline'
import persistedUiStates from '@/web/states/persistedUiStates'
import Controls from './Controls'
import Cover from './Cover'
@ -34,9 +34,7 @@ const NowPlaying = () => {
{/* Info & Controls */}
<div className='m-3 flex flex-col items-center rounded-20 bg-white/60 p-5 font-medium backdrop-blur-3xl dark:bg-black/70'>
{/* Track Info */}
<div className='line-clamp-1 text-lg text-black dark:text-white'>
{track?.name}
</div>
<div className='line-clamp-1 text-lg text-black dark:text-white'>{track?.name}</div>
<ArtistInline
artists={track?.ar || []}
className='text-black/30 dark:text-white/30'

View file

@ -5,6 +5,7 @@ const Tabs = ({
value,
onChange,
className,
style,
}: {
tabs: {
id: string
@ -13,9 +14,10 @@ const Tabs = ({
value: string
onChange: (id: string) => void
className?: string
style?: React.CSSProperties
}) => {
return (
<div className={cx('no-scrollbar flex overflow-y-auto', className)}>
<div className={cx('no-scrollbar flex overflow-y-auto', className)} style={style}>
{tabs.map(tab => (
<div
key={tab.id}

View file

@ -30,11 +30,9 @@ const Background = () => {
transition={{ ease }}
className={cx(
'absolute inset-0 z-0 bg-contain bg-repeat-x',
window.env?.isElectron && 'rounded-t-24',
css`
background-image: url(${topbarBackground});
`
window.env?.isElectron && 'rounded-t-24'
)}
style={{ backgroundImage: `url(${topbarBackground})` }}
></motion.div>
)}
</AnimatePresence>

View file

@ -67,14 +67,14 @@ const Info = ({
)}
{/* Description */}
{!isMobile && (
{!isMobile && description && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className='line-clamp-3 mt-6 whitespace-pre-wrap text-14 font-bold dark:text-white/40'
dangerouslySetInnerHTML={{
__html: description || '',
__html: description,
}}
></motion.div>
)}

View file

@ -1,26 +0,0 @@
import { IpcChannels } from '@/shared/IpcChannels'
import { useQuery } from '@tanstack/react-query'
export default function useAppleMusicAlbum(props: {
id?: number
name?: string
artist?: string
}) {
const { id, name, artist } = props
return useQuery(
['useAppleMusicAlbum', props],
async () => {
if (!id || !name || !artist) return
return window.ipcRenderer?.invoke(IpcChannels.GetAlbumFromAppleMusic, {
id,
name,
artist,
})
},
{
enabled: !!id && !!name && !!artist,
refetchOnWindowFocus: false,
refetchInterval: false,
}
)
}

View file

@ -1,36 +0,0 @@
import { AppleMusicArtist } from '@/shared/AppleMusic'
import { APIs } from '@/shared/CacheAPIs'
import { IpcChannels } from '@/shared/IpcChannels'
import { useQuery } from '@tanstack/react-query'
export default function useAppleMusicArtist(props: {
id?: number
name?: string
}) {
const { id, name } = props
return useQuery(
['useAppleMusicArtist', props],
async () => {
if (!id || !name) return
const cache = await window.ipcRenderer?.invoke(IpcChannels.GetApiCache, {
api: APIs.AppleMusicArtist,
query: {
id,
},
})
if (cache) return cache
return window.ipcRenderer?.invoke(IpcChannels.GetArtistFromAppleMusic, {
id,
name,
})
},
{
enabled: !!id && !!name,
refetchOnWindowFocus: false,
refetchInterval: false,
}
)
}

View file

@ -9,7 +9,7 @@
content="script-src 'self' 'unsafe-inline' www.googletagmanager.com blob:;" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>R3Play</title>
<title>R3PLAY</title>
</head>
<body>

View file

@ -1,10 +1,7 @@
import useAlbum from '@/web/api/hooks/useAlbum'
import useUserAlbums, {
useMutationLikeAAlbum,
} from '@/web/api/hooks/useUserAlbums'
import useUserAlbums, { useMutationLikeAAlbum } from '@/web/api/hooks/useUserAlbums'
import Icon from '@/web/components/Icon'
import TrackListHeader from '@/web/components/TrackListHeader'
import useAppleMusicAlbum from '@/web/hooks/useAppleMusicAlbum'
import useVideoCover from '@/web/hooks/useVideoCover'
import player from '@/web/states/player'
import { formatDuration } from '@/web/utils/common'
@ -13,6 +10,7 @@ import { useMemo } from 'react'
import toast from 'react-hot-toast'
import { useParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import useAppleMusicAlbum from '@/web/api/hooks/useAppleMusicAlbum'
const Header = () => {
const { t, i18n } = useTranslation()
@ -25,51 +23,31 @@ const Header = () => {
})
const album = useMemo(() => albumRaw?.album, [albumRaw])
const { data: albumFromApple, isLoading: isLoadingAlbumFromApple } =
useAppleMusicAlbum({
id: album?.id,
name: album?.name,
artist: album?.artist.name,
})
const { data: videoCoverFromRemote } = useVideoCover({
id: album?.id,
name: album?.name,
artist: album?.artist.name,
enabled: !window.env?.isElectron,
})
const { data: appleMusicAlbum, isLoading: isLoadingAppleMusicAlbum } = useAppleMusicAlbum(
album?.id || 0
)
// For <Cover />
const cover = album?.picUrl
const videoCover =
albumFromApple?.attributes?.editorialVideo?.motionSquareVideo1x1?.video ||
videoCoverFromRemote?.video
const videoCover = appleMusicAlbum?.editorialVideo
// For <Info />
const title = album?.name
const creatorName = album?.artist.name
const creatorLink = `/artist/${album?.artist.id}`
const description = isLoadingAlbumFromApple
const description = isLoadingAppleMusicAlbum
? ''
: albumFromApple?.attributes?.editorialNotes?.standard || album?.description
: appleMusicAlbum?.editorialNote?.[i18n.language.replace('-', '_')] || album?.description
const extraInfo = useMemo(() => {
const duration = album?.songs?.reduce((acc, cur) => acc + cur.dt, 0) || 0
const albumDuration = formatDuration(
duration,
i18n.language,
'hh[hr] mm[min]'
)
const albumDuration = formatDuration(duration, i18n.language, 'hh[hr] mm[min]')
return (
<>
{album?.mark === 1056768 && (
<Icon
name='explicit'
className='mb-px mr-1 h-3 w-3 lg:h-3.5 lg:w-3.5'
/>
<Icon name='explicit' className='mb-px mr-1 h-3 w-3 lg:h-3.5 lg:w-3.5' />
)}{' '}
{dayjs(album?.publishTime || 0).year()} ·{' '}
{t('common.track-with-count', { count: album?.songs?.length })},{' '}
{albumDuration}
{t('common.track-with-count', { count: album?.songs?.length })}, {albumDuration}
</>
)
}, [album?.mark, album?.publishTime, album?.songs, i18n.language, t])

View file

@ -8,6 +8,8 @@ const ArtistVideos = () => {
const params = useParams()
const { data: videos } = useArtistMV({ id: Number(params.id) || 0 })
if (!videos?.mvs?.length) return null
return (
<div>
<div className='mb-6 mt-10 text-12 font-medium uppercase text-neutral-300'>
@ -16,17 +18,12 @@ const ArtistVideos = () => {
<div className='grid grid-cols-3 gap-6'>
{videos?.mvs?.slice(0, 6)?.map(video => (
<div
key={video.id}
onClick={() => (uiStates.playingVideoID = video.id)}
>
<div key={video.id} onClick={() => (uiStates.playingVideoID = video.id)}>
<img
src={video.imgurl16v9}
className='aspect-video w-full rounded-24 border border-white/5 object-contain'
/>
<div className='mt-2 text-12 font-medium text-neutral-600'>
{video.name}
</div>
<div className='mt-2 text-12 font-medium text-neutral-600'>{video.name}</div>
</div>
))}
</div>

View file

@ -1,23 +1,16 @@
import useIsMobile from '@/web/hooks/useIsMobile'
import useAppleMusicArtist from '@/web/hooks/useAppleMusicArtist'
import useAppleMusicArtist from '@/web/api/hooks/useAppleMusicArtist'
import { cx, css } from '@emotion/css'
import { useTranslation } from 'react-i18next'
import i18next from 'i18next'
const ArtistInfo = ({
artist,
isLoading,
}: {
artist?: Artist
isLoading: boolean
}) => {
const { t } = useTranslation()
const ArtistInfo = ({ artist, isLoading }: { artist?: Artist; isLoading: boolean }) => {
const { t, i18n } = useTranslation()
const isMobile = useIsMobile()
const { data: artistFromApple, isLoading: isLoadingArtistFromApple } =
useAppleMusicArtist({
id: artist?.id,
name: artist?.name,
})
const { data: artistFromApple, isLoading: isLoadingArtistFromApple } = useAppleMusicArtist(
artist?.id || 0
)
return (
<div>
@ -27,9 +20,7 @@ const ArtistInfo = ({
<span className='rounded-full bg-white/10'>PLACEHOLDER</span>
</div>
) : (
<div className='text-28 font-semibold text-white/70 lg:text-32'>
{artist?.name}
</div>
<div className='text-28 font-semibold text-white/70 lg:text-32'>{artist?.name}</div>
)}
{/* Type */}
@ -38,9 +29,7 @@ const ArtistInfo = ({
<span className='rounded-full bg-white/10'>Artist</span>
</div>
) : (
<div className='mt-2.5 text-24 font-medium text-white/40 lg:mt-6'>
Artist
</div>
<div className='mt-2.5 text-24 font-medium text-white/40 lg:mt-6'>Artist</div>
)}
{/* Counts */}
@ -67,9 +56,7 @@ const ArtistInfo = ({
`
)}
>
<span className='rounded-full bg-white/10'>
PLACEHOLDER1234567890
</span>
<span className='rounded-full bg-white/10'>PLACEHOLDER1234567890</span>
</div>
) : (
<div
@ -80,7 +67,7 @@ const ArtistInfo = ({
`
)}
>
{artistFromApple?.attributes?.artistBio || artist?.briefDesc}
{artistFromApple?.artistBio?.[i18n.language.replace('-', '_')] || artist?.briefDesc}
</div>
))}
</div>

View file

@ -1,25 +1,18 @@
import { isIOS, isSafari, resizeImage } from '@/web/utils/common'
import { resizeImage } from '@/web/utils/common'
import Image from '@/web/components/Image'
import { cx, css } from '@emotion/css'
import useAppleMusicArtist from '@/web/hooks/useAppleMusicArtist'
import { useEffect, useRef, useState } from 'react'
import Hls from 'hls.js'
import { motion } from 'framer-motion'
import useAppleMusicArtist from '@/web/api/hooks/useAppleMusicArtist'
import { useEffect } from 'react'
import uiStates from '@/web/states/uiStates'
import VideoCover from '@/web/components/VideoCover'
const Cover = ({ artist }: { artist?: Artist }) => {
const { data: artistFromApple, isLoading: isLoadingArtistFromApple } =
useAppleMusicArtist({
id: artist?.id,
name: artist?.name,
})
const { data: artistFromApple, isLoading: isLoadingArtistFromApple } = useAppleMusicArtist(
artist?.id || 0
)
const video =
artistFromApple?.attributes?.editorialVideo?.motionArtistSquare1x1?.video
const cover = isLoadingArtistFromApple
? ''
: artistFromApple?.attributes?.artwork?.url || artist?.img1v1Url
const video = artistFromApple?.editorialVideo
const cover = isLoadingArtistFromApple ? '' : artistFromApple?.artwork || artist?.img1v1Url || ''
useEffect(() => {
if (cover) uiStates.blurBackgroundImage = cover
@ -40,14 +33,7 @@ const Cover = ({ artist }: { artist?: Artist }) => {
'aspect-square h-full w-full lg:z-10',
video ? 'opacity-0' : 'opacity-100'
)}
src={resizeImage(
isLoadingArtistFromApple
? ''
: artistFromApple?.attributes?.artwork?.url ||
artist?.img1v1Url ||
'',
'lg'
)}
src={resizeImage(isLoadingArtistFromApple ? '' : cover, 'lg')}
/>
{video && <VideoCover source={video} />}

View file

@ -1,8 +1,5 @@
import Tabs from '@/web/components/Tabs'
import {
fetchDailyRecommendPlaylists,
fetchRecommendedPlaylists,
} from '@/web/api/playlist'
import { fetchDailyRecommendPlaylists, fetchRecommendedPlaylists } from '@/web/api/playlist'
import { PlaylistApiNames } from '@/shared/api/Playlists'
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
@ -33,10 +30,7 @@ const Recommend = () => {
const playlists =
isLoadingDaily || isLoading
? []
: [
...(dailyRecommendPlaylists?.recommend || []),
...(recommendedPlaylists?.result || []),
]
: [...(dailyRecommendPlaylists?.recommend || []), ...(recommendedPlaylists?.result || [])]
return <CoverRowVirtual playlists={playlists} />
@ -69,10 +63,12 @@ const Browse = () => {
'pointer-events-none fixed top-0 left-10 z-10 hidden lg:block',
css`
height: 230px;
right: ${playerWidth + 32}px;
background-image: url(${topbarBackground});
`
)}
style={{
right: `${playerWidth + 32}px`,
backgroundImage: `url(${topbarBackground})`,
}}
></div>
<Tabs

View file

@ -17,13 +17,12 @@ import { throttle } from 'lodash-es'
import { useTranslation } from 'react-i18next'
import VideoRow from '@/web/components/VideoRow'
import useUserVideos from '@/web/api/hooks/useUserVideos'
import persistedUiStates from '@/web/states/persistedUiStates'
const Albums = () => {
const { data: albums } = useUserAlbums()
return (
<CoverRow albums={albums?.data} itemTitle='name' itemSubtitle='artist' />
)
return <CoverRow albums={albums?.data} itemTitle='name' itemSubtitle='artist' />
}
const Playlists = () => {
@ -65,9 +64,8 @@ const CollectionTabs = ({ showBg }: { showBg: boolean }) => {
]
const { librarySelectedTab: selectedTab } = useSnapshot(uiStates)
const setSelectedTab = (
id: 'playlists' | 'albums' | 'artists' | 'videos'
) => {
const { minimizePlayer } = useSnapshot(persistedUiStates)
const setSelectedTab = (id: 'playlists' | 'albums' | 'artists' | 'videos') => {
uiStates.librarySelectedTab = id
}
@ -81,13 +79,16 @@ const CollectionTabs = ({ showBg }: { showBg: boolean }) => {
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className={cx(
'pointer-events-none fixed top-0 left-10 z-10 hidden lg:block',
'pointer-events-none fixed top-0 right-0 left-10 z-10 hidden lg:block',
css`
height: 230px;
right: ${playerWidth + 32}px;
background-image: url(${topbarBackground});
background-repeat: repeat;
`
)}
style={{
right: `${minimizePlayer ? 0 : playerWidth + 32}px`,
backgroundImage: `url(${topbarBackground})`,
}}
></motion.div>
)}
</AnimatePresence>
@ -99,12 +100,10 @@ const CollectionTabs = ({ showBg }: { showBg: boolean }) => {
setSelectedTab(id)
scrollToBottom(true)
}}
className={cx(
'sticky z-10 -mb-10 px-2.5 lg:px-0',
css`
top: ${topbarHeight}px;
`
)}
className={cx('sticky z-10 -mb-10 px-2.5 lg:px-0')}
style={{
top: `${topbarHeight}px`,
}}
/>
</>
)
@ -114,8 +113,7 @@ const Collections = () => {
const { librarySelectedTab: selectedTab } = useSnapshot(uiStates)
const observePoint = useRef<HTMLDivElement | null>(null)
const { onScreen: isScrollReachBottom } =
useIntersectionObserver(observePoint)
const { onScreen: isScrollReachBottom } = useIntersectionObserver(observePoint)
const onScroll = throttle(() => {
if (isScrollReachBottom) return
@ -126,13 +124,11 @@ const Collections = () => {
<div>
<CollectionTabs showBg={isScrollReachBottom} />
<div
className={cx(
'no-scrollbar overflow-y-auto px-2.5 pt-16 pb-16 lg:px-0',
css`
height: calc(100vh - ${topbarHeight}px);
`
)}
className={cx('no-scrollbar overflow-y-auto px-2.5 pt-16 pb-16 lg:px-0')}
onScroll={onScroll}
style={{
height: `calc(100vh - ${topbarHeight}px)`,
}}
>
{selectedTab === 'albums' && <Albums />}
{selectedTab === 'playlists' && <Playlists />}

View file

@ -1,4 +1,4 @@
export const appName = 'R3Play'
export const appName = 'R3PLAY'
// 动画曲线
export const ease: [number, number, number, number] = [0.4, 0, 0.2, 1]

View file

@ -1,7 +1,7 @@
/// <reference types="vitest" />
import react from '@vitejs/plugin-react-swc'
import dotenv from 'dotenv'
import path, { join } from 'path'
import { join } from 'path'
import { defineConfig } from 'vite'
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
import { visualizer } from 'rollup-plugin-visualizer'
@ -9,7 +9,7 @@ import { VitePWA } from 'vite-plugin-pwa'
import filenamesToType from './vitePluginFilenamesToType'
import { appName } from './utils/const'
dotenv.config({ path: path.resolve(process.cwd(), '../../.env') })
dotenv.config({ path: join(__dirname, '../../.env') })
const IS_ELECTRON = process.env.IS_ELECTRON
/**
@ -37,33 +37,33 @@ export default defineConfig({
/**
* @see https://vite-plugin-pwa.netlify.app/guide/generate.html
*/
// VitePWA({
// registerType: 'autoUpdate',
// manifest: {
// name: appName,
// short_name: appName,
// description: 'Description of your app',
// theme_color: '#000',
// icons: [
// {
// src: 'pwa-192x192.png',
// sizes: '192x192',
// type: 'image/png',
// },
// {
// src: 'pwa-512x512.png',
// sizes: '512x512',
// type: 'image/png',
// },
// {
// src: 'pwa-512x512.png',
// sizes: '512x512',
// type: 'image/png',
// purpose: 'any maskable',
// },
// ],
// },
// }),
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: appName,
short_name: appName,
description: 'Description of your app',
theme_color: '#000',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable',
},
],
},
}),
/**
* @see https://github.com/vbenjs/vite-plugin-svg-icons
@ -90,27 +90,36 @@ export default defineConfig({
},
},
server: {
port: Number(process.env['ELECTRON_WEB_SERVER_PORT'] || 42710),
port: Number(process.env.ELECTRON_WEB_SERVER_PORT || 42710),
strictPort: IS_ELECTRON ? true : false,
proxy: {
'/netease/': {
// target: `http://192.168.50.111:${
target: `http://127.0.0.1:${process.env.ELECTRON_DEV_NETEASE_API_PORT || 30001}`,
changeOrigin: true,
rewrite: path => (IS_ELECTRON ? path : path.replace(/^\/netease/, '')),
},
[`/${appName.toLowerCase()}/video-cover`]: {
target: `http://168.138.40.199:51324`,
changeOrigin: true,
},
[`/${appName.toLowerCase()}/`]: {
'/r3play/': {
target: `http://127.0.0.1:${process.env.ELECTRON_DEV_NETEASE_API_PORT || 30001}`,
changeOrigin: true,
},
// [`/${appName.toLowerCase()}/apple-music/`]: {
// target: `http://168.138.174.244:35530/`,
// changeOrigin: true,
// rewrite: path => path.replace(/^\/r3play/, ''),
// },
// [`/${appName.toLowerCase()}/`]: {
// target: `http://127.0.0.1:${process.env.ELECTRON_DEV_NETEASE_API_PORT || 30001}`,
// changeOrigin: true,
// },
// '/': {
// target: `http://127.0.0.1:${process.env.ELECTRON_DEV_NETEASE_API_PORT || 30001}`,
// changeOrigin: true,
// // rewrite: path => (IS_ELECTRON ? path : path.replace(/^\/netease/, '')),
// },
},
},
preview: {
port: Number(process.env['ELECTRON_WEB_SERVER_PORT'] || 42710),
port: Number(process.env.ELECTRON_WEB_SERVER_PORT || 42710),
},
test: {
environment: 'jsdom',