Compare commits
433 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e3e038d42 | |||
| b375238baf | |||
| 02b06a07be | |||
| d7f21b3c6b | |||
| 1113ef972f | |||
| 46a2c29b22 | |||
| ebcb7bc4d1 | |||
| 0b62bee3a6 | |||
| d3503af12c | |||
| 571ea3c653 | |||
| f0e518d3c8 | |||
| 47dc83f360 | |||
| 8cbc25a932 | |||
| 0cba405b45 | |||
| 45b80ac395 | |||
| 8b0fe69e1c | |||
| 25e621372c | |||
| 14f4649b93 | |||
| 1a87adb728 | |||
| bb9bf7ba6a | |||
| fb7e7bfa3e | |||
| a8a14a62c0 | |||
| cd836d54db | |||
| c90c43944d | |||
| fd7468a4fe | |||
| c4f9868a6b | |||
| fbb0907a70 | |||
| 201ee895f9 | |||
| 51be0153d3 | |||
| 29a9a11085 | |||
| 65f28bb9dc | |||
| fd264daffc | |||
| 18e35f2ba9 | |||
| 487e9be8ec | |||
| d9049ed066 | |||
| 6e62448b88 | |||
| ec457d5125 | |||
| d45b01625b | |||
| 2defa5cc13 | |||
| 9cc9c3a87f | |||
| 153d8ce6ce | |||
| 5e33212112 | |||
| 7d6990eb90 | |||
| d75ea94161 | |||
| 1badecc20a | |||
| c90a56811d | |||
| 4e5e3bc9a1 | |||
| c8397bb5ef | |||
| 0ae53b023c | |||
| 1acfa93f1a | |||
| b60ba27892 | |||
| 7ddba8ede7 | |||
| a8bd53b757 | |||
| 877b2e9f3b | |||
| 663893dccb | |||
| 96ace40fc3 | |||
| e9de9f5b65 | |||
| c92c94cf1a | |||
| 1d664bbbd7 | |||
| 7c59722f0a | |||
| 3f813b1a26 | |||
| 13d6758500 | |||
| 2b6323c396 | |||
| 8338aaf18d | |||
| 5f5c3bbb11 | |||
| 2a9d30e43d | |||
| d1e5571163 | |||
| e542fcb8aa | |||
| 1111fd00a1 | |||
| 6ae8046781 | |||
| 689b40eb91 | |||
| b3bdff446d | |||
| 8686a7c592 | |||
| 59a851f8c8 | |||
| fedef48411 | |||
| e3fc99cf82 | |||
| f09ad1da89 | |||
| 1ab75f7187 | |||
| 3d1f36e85a | |||
| 23e791c829 | |||
| aaaaee7043 | |||
| fca135ce2b | |||
| 72b4a60c7b | |||
| ff68de8c09 | |||
| c8663db4ba | |||
| 95780f1969 | |||
| 1327d58b23 | |||
| f6d239d87c | |||
| 80fb844d3c | |||
| b35d3c3256 | |||
| 14453a8524 | |||
| d0aba6e16e | |||
| 0b207c78e7 | |||
| ee83fdba71 | |||
| adfa748bfb | |||
| 505974320f | |||
| 5896d886d7 | |||
| f6d74ce9c3 | |||
| f443c466b0 | |||
| 8029712b55 | |||
| 4d5085f230 | |||
| 9f60769b65 | |||
| e618ac7590 | |||
| 9f55238b74 | |||
| 93e00e7afb | |||
| 8e83beffcc | |||
| 230fa33525 | |||
| ed070850a4 | |||
| 2072f9554e | |||
| 2aaf3c34c8 | |||
| b57f601e1b | |||
| 51f8415025 | |||
| e6bcb4e237 | |||
| c9dbf9b5be | |||
| 0a13d047bb | |||
| 84bec824f2 | |||
| 03a4a1da55 | |||
| 2c9509b58d | |||
| 42ea5af2eb | |||
| ebf0d3b47f | |||
| e44b8592e5 | |||
| f9338aafcd | |||
| 3aec139f58 | |||
| 8a367b00a3 | |||
| 46374ef2b5 | |||
| febe1a703c | |||
| 853770ea8e | |||
| 48eaddbeda | |||
| 0a26c489b6 | |||
| bbee3fc655 | |||
| a8dfc7bcd6 | |||
| 74384639de | |||
| 20524452ae | |||
| f274801be6 | |||
| 9d18384b2d | |||
| 92d7560362 | |||
| 47d84fae2d | |||
| c3d8791455 | |||
| 3d6f5a2748 | |||
| 61403510d4 | |||
| e796b031ea | |||
| 2d62b9d72d | |||
| f5cbcace64 | |||
| e7c15ef5f1 | |||
| 31eb22f968 | |||
| 713260bfc9 | |||
| ba00538cc3 | |||
| dd2dd797a1 | |||
| eec556d34a | |||
| 7378fd1f20 | |||
| 6821735f65 | |||
| 1cb0a1d72a | |||
| 287f1dc0e1 | |||
| 6dd9333dbb | |||
| 55937e71db | |||
| c0e3174d09 | |||
| 440cc04fbc | |||
| 6cd27c3e88 | |||
| 85964bfded | |||
| 8b4a2d1ac0 | |||
| 9bcefb3105 | |||
| 4029127018 | |||
| f9ddd3140a | |||
| 651af8539a | |||
| 4e4eca14ec | |||
| 1ec70bfa78 | |||
| c3f97dfa4c | |||
| bba27c5ddb | |||
| 78860db537 | |||
| ece7fecc76 | |||
| 919016ca5a | |||
| b8dfbf9d49 | |||
| 179129b7cb | |||
| 817675ee0e | |||
| 57cdb0eb69 | |||
| 8233a56def | |||
| 0c54b79c09 | |||
| 3fb9853eb6 | |||
| 1de89071e8 | |||
| be37dada13 | |||
| c27a9a8ffb | |||
| be0792a5c7 | |||
| 37e4940c2e | |||
| e965bd2663 | |||
| b9caa73405 | |||
| 0ba8d5bf70 | |||
| 1fc5e9a0e8 | |||
| f09227d963 | |||
| 47ecbf0601 | |||
| 481258484c | |||
| 3dcb0dc4ed | |||
| d64040f3f0 | |||
| 63a77ae68c | |||
| e980e31bd2 | |||
| 3b5dff795f | |||
| 8129a3994b | |||
| 734b632c6c | |||
| 34f05fa2a5 | |||
| f74e02eb09 | |||
| 287fbab29a | |||
| e9d1e4a597 | |||
| 70f893e5e9 | |||
| 30e52ebb54 | |||
| 22af76b4d6 | |||
| cb7bf438e9 | |||
| a1b5c21a84 | |||
| 4c5fa0750b | |||
| 22160ba59f | |||
| ba8e23e8d4 | |||
| 7fa4641dfe | |||
| 4167af098f | |||
| c5f551e963 | |||
| fbd0e5b27b | |||
| 5877b8cc6f | |||
| 23f4bfde99 | |||
| 4898fa7dcf | |||
| a6990fd732 | |||
| 2fac9efc1b | |||
| a3a84766e4 | |||
| 0e9a77ffe0 | |||
| 8f7e6a5222 | |||
| 736945d6ef | |||
| f97e855f51 | |||
| d6e628099c | |||
| d7ca25525c | |||
| 72099cb1fe | |||
| eeefe9d9dc | |||
| 86c3e54119 | |||
| ea0737cf1f | |||
| f4eaacc64a | |||
| 7f6efbe6dc | |||
| 72811dbedb | |||
| 493e13ebc0 | |||
| 14aeee888f | |||
| cbc08d6f03 | |||
| 77703b904f | |||
| 762644d23d | |||
| 75403078d2 | |||
| 255a131f3b | |||
| e56350c1c2 | |||
| aaa1b5f63a | |||
| 3d409bb6f1 | |||
| 7ab532be07 | |||
| 946b73d215 | |||
| 2f0634dc03 | |||
| f8ecb3fc53 | |||
| 01608fa875 | |||
| 14a6766072 | |||
| 0fa5b6496f | |||
| 43c11ab6e3 | |||
| 41a901f3c4 | |||
| 2bdc664619 | |||
| 8835fc640a | |||
| f92cd89c46 | |||
| a1a113d3c6 | |||
| 3f78c3f420 | |||
| f10912d930 | |||
| 98fa47348c | |||
| d38c846e80 | |||
| 007a099951 | |||
| 9622cd346c | |||
| c3c1f4cc5f | |||
| d97fe4c621 | |||
| 7e5733db34 | |||
| d1dde2428f | |||
| 190dd71b3c | |||
| feb61c28d7 | |||
| f380eccc68 | |||
| cf43bf360e | |||
| 48dfd469ed | |||
| 5dd860735d | |||
| 7cd2077dcd | |||
| 7430bba853 | |||
| 782c351ca6 | |||
| 3aef2a80a7 | |||
| 85a10c799a | |||
| 9eef570740 | |||
| 58f38b2655 | |||
| 85d2576bdc | |||
| 23f9bd4e9f | |||
| 8eb0029bb8 | |||
| c8a0df4759 | |||
| e7bc29a8f1 | |||
| 5295c69f46 | |||
| f58552be84 | |||
| cd57142caf | |||
| 86ad2d0383 | |||
| 7d5aa6fd13 | |||
| f2ef630921 | |||
| 9250b30249 | |||
| 2b16cce0aa | |||
| 34870556b4 | |||
| 7e2d9bd585 | |||
| 691bc8f1ef | |||
| 5dbc0c61c5 | |||
| 0bc1ee3492 | |||
| 7403a46f91 | |||
| 8ffb81093d | |||
| d312c3c70a | |||
| cd66a9dccb | |||
| f2690b262f | |||
| 63c5a83911 | |||
| 17b1acad9d | |||
| e7c7eb3ec0 | |||
| fa0a21a021 | |||
| 791088deb6 | |||
| 9c1a2a4a8d | |||
| 6d092d9ebc | |||
| 73997cf6c7 | |||
| 1d074dae2e | |||
| a878875f83 | |||
| d055ae89e0 | |||
| f83639d5f8 | |||
| 97ccf3bc6d | |||
| 76805a0b19 | |||
| 0103a84358 | |||
| 611cbc6dd9 | |||
| 011f260e94 | |||
| e937425f4f | |||
| bc2624bffd | |||
| 4f21c26e5d | |||
| e6a4ce2e64 | |||
| 5b98238b3a | |||
| d96c0d547a | |||
| 3c62de8347 | |||
| 07d4dc37b5 | |||
| 64c5f25d18 | |||
| 098e86b1f4 | |||
| adc3e421f6 | |||
| d289797d65 | |||
| 6218b27117 | |||
| 549db7b1bf | |||
| 8ee99adb2d | |||
| da519c2250 | |||
| 7cd33ad388 | |||
| 8ae368ea4f | |||
| 22e31b92a4 | |||
| a308efaf06 | |||
| 977cb89481 | |||
| 0c3b030b13 | |||
| 86080c7875 | |||
| b71c3c7c53 | |||
| debdb92dcf | |||
| ba6f2a1637 | |||
| 7c6f62023a | |||
| de50002ea7 | |||
| 41a251c2ac | |||
| 10d7664733 | |||
| fed96d1fce | |||
| 106fc90c4a | |||
| c1c6ce33e4 | |||
| 26bc7d23ae | |||
| 30dc833b79 | |||
| 292737d53c | |||
| 652c4a1f81 | |||
| fb158bc069 | |||
| 51c2731b07 | |||
| 93530008a9 | |||
| 6747fbb701 | |||
| 06d253228a | |||
| c8b1b4d394 | |||
| 0320fe6dcc | |||
| 1f36978bb9 | |||
| 6a01d44600 | |||
| 35f9798bed | |||
| 897af4661b | |||
| 3df2915f5f | |||
| 02caf896ff | |||
| 7dd56bfb9b | |||
| fe59011882 | |||
| c854fd0a5b | |||
| 645b4fe332 | |||
| e5f24b3160 | |||
| fff1315fa5 | |||
| ba0543f861 | |||
| 30c4d5baf1 | |||
| e8f7ae637f | |||
| b5fa6f0baa | |||
| c4fb9a2e72 | |||
| 2cefc092ce | |||
| a7ea54cf4b | |||
| deb4e34895 | |||
| 33ecf9faa6 | |||
| cf6325d0ba | |||
| c12c1bad73 | |||
| cf9ed31dfd | |||
| c296927bbb | |||
| 32ebe6b739 | |||
| c85a7079eb | |||
| 822060b82c | |||
| 547fe7be38 | |||
| ccf5588435 | |||
| 4cb54bc9da | |||
| ce72ff5e8d | |||
| 71b9cace53 | |||
| 5637327e8a | |||
| a1072b461f | |||
| 3fb24d5f64 | |||
| e45252d16c | |||
| 48ef7a987f | |||
| 58d912065b | |||
| d8130f48e2 | |||
| 89afa9b836 | |||
| 684ba13175 | |||
| 2399105f6c | |||
| d42f4dbe4f | |||
| cf32a7ff21 | |||
| 5eea3d7e01 | |||
| e2e3a50f1f | |||
| 4c98afb613 | |||
| d7f24262fd | |||
| 6056504f00 | |||
| cef92243f5 | |||
| 8d5c82b0c6 | |||
| 003fb26c60 | |||
| 4eb90d20a2 | |||
| cf489d3934 | |||
| 416476cc66 | |||
| bdc3daf6da | |||
| 129515d57a | |||
| 76ca03d8e3 | |||
| e49fe6c452 | |||
| ec7a053a74 | |||
| 9e4e6172c3 | |||
| eca26e912f | |||
| f9e410a1f5 | |||
| 87abd0c6f5 | |||
| e3665e6407 | |||
| c87905f6c2 | |||
| 2100c1495d | |||
| b5da8aeb55 | |||
| 5eeded6c72 | |||
| 346b8be122 |
@@ -7,6 +7,10 @@ import { dependencies as externals } from '../../release/app/package.json';
|
|||||||
import webpackPaths from './webpack.paths';
|
import webpackPaths from './webpack.paths';
|
||||||
import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin';
|
import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin';
|
||||||
|
|
||||||
|
const createStyledComponentsTransformer = require('typescript-plugin-styled-components').default;
|
||||||
|
|
||||||
|
const styledComponentsTransformer = createStyledComponentsTransformer();
|
||||||
|
|
||||||
const configuration: webpack.Configuration = {
|
const configuration: webpack.Configuration = {
|
||||||
externals: [...Object.keys(externals || {})],
|
externals: [...Object.keys(externals || {})],
|
||||||
|
|
||||||
@@ -20,6 +24,7 @@ const configuration: webpack.Configuration = {
|
|||||||
options: {
|
options: {
|
||||||
// Remove this line to enable type checking in webpack builds
|
// Remove this line to enable type checking in webpack builds
|
||||||
transpileOnly: true,
|
transpileOnly: true,
|
||||||
|
getCustomTransformers: () => ({ before: [styledComponentsTransformer] }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import 'webpack-dev-server';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import HtmlWebpackPlugin from 'html-webpack-plugin';
|
||||||
|
import webpack from 'webpack';
|
||||||
|
import { merge } from 'webpack-merge';
|
||||||
|
|
||||||
|
import checkNodeEnv from '../scripts/check-node-env';
|
||||||
|
import baseConfig from './webpack.config.base';
|
||||||
|
import webpackPaths from './webpack.paths';
|
||||||
|
|
||||||
|
// When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's
|
||||||
|
// at the dev webpack config is not accidentally run in a production environment
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
checkNodeEnv('development');
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = process.env.PORT || 4343;
|
||||||
|
|
||||||
|
const configuration: webpack.Configuration = {
|
||||||
|
devtool: 'inline-source-map',
|
||||||
|
|
||||||
|
mode: 'development',
|
||||||
|
|
||||||
|
target: ['web'],
|
||||||
|
|
||||||
|
entry: [path.join(webpackPaths.srcRemotePath, 'index.tsx')],
|
||||||
|
|
||||||
|
output: {
|
||||||
|
path: webpackPaths.dllPath,
|
||||||
|
publicPath: '/',
|
||||||
|
filename: 'remote.js',
|
||||||
|
library: {
|
||||||
|
type: 'umd',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.s?css$/,
|
||||||
|
use: [
|
||||||
|
'style-loader',
|
||||||
|
{
|
||||||
|
loader: 'css-loader',
|
||||||
|
options: {
|
||||||
|
modules: true,
|
||||||
|
sourceMap: true,
|
||||||
|
importLoaders: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'sass-loader',
|
||||||
|
],
|
||||||
|
include: /\.module\.s?(c|a)ss$/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.s?css$/,
|
||||||
|
use: ['style-loader', 'css-loader', 'sass-loader'],
|
||||||
|
exclude: /\.module\.s?(c|a)ss$/,
|
||||||
|
},
|
||||||
|
// Fonts
|
||||||
|
{
|
||||||
|
test: /\.(woff|woff2|eot|ttf|otf)$/i,
|
||||||
|
type: 'asset/resource',
|
||||||
|
},
|
||||||
|
// Images
|
||||||
|
{
|
||||||
|
test: /\.(png|svg|jpg|jpeg|gif)$/i,
|
||||||
|
type: 'asset/resource',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
new webpack.NoEmitOnErrorsPlugin(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create global constants which can be configured at compile time.
|
||||||
|
*
|
||||||
|
* Useful for allowing different behaviour between development builds and
|
||||||
|
* release builds
|
||||||
|
*
|
||||||
|
* NODE_ENV should be production so that modules do not perform certain
|
||||||
|
* development checks
|
||||||
|
*
|
||||||
|
* By default, use 'development' as NODE_ENV. This can be overriden with
|
||||||
|
* 'staging', for example, by changing the ENV variables in the npm scripts
|
||||||
|
*/
|
||||||
|
new webpack.EnvironmentPlugin({
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
}),
|
||||||
|
|
||||||
|
new webpack.LoaderOptionsPlugin({
|
||||||
|
debug: true,
|
||||||
|
}),
|
||||||
|
|
||||||
|
new HtmlWebpackPlugin({
|
||||||
|
filename: path.join('index.html'),
|
||||||
|
template: path.join(webpackPaths.srcRemotePath, 'index.ejs'),
|
||||||
|
favicon: path.join(webpackPaths.assetsPath, 'icons', 'favicon.ico'),
|
||||||
|
minify: {
|
||||||
|
collapseWhitespace: true,
|
||||||
|
removeAttributeQuotes: true,
|
||||||
|
removeComments: true,
|
||||||
|
},
|
||||||
|
isBrowser: true,
|
||||||
|
env: process.env.NODE_ENV,
|
||||||
|
isDevelopment: process.env.NODE_ENV !== 'production',
|
||||||
|
nodeModules: webpackPaths.appNodeModulesPath,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
|
||||||
|
node: {
|
||||||
|
__dirname: false,
|
||||||
|
__filename: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default merge(baseConfig, configuration);
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* Build config for electron renderer process
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import CssMinimizerPlugin from 'css-minimizer-webpack-plugin';
|
||||||
|
import HtmlWebpackPlugin from 'html-webpack-plugin';
|
||||||
|
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
|
||||||
|
import TerserPlugin from 'terser-webpack-plugin';
|
||||||
|
import webpack from 'webpack';
|
||||||
|
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
|
||||||
|
import { merge } from 'webpack-merge';
|
||||||
|
|
||||||
|
import checkNodeEnv from '../scripts/check-node-env';
|
||||||
|
import deleteSourceMaps from '../scripts/delete-source-maps';
|
||||||
|
import baseConfig from './webpack.config.base';
|
||||||
|
import webpackPaths from './webpack.paths';
|
||||||
|
|
||||||
|
checkNodeEnv('production');
|
||||||
|
deleteSourceMaps();
|
||||||
|
|
||||||
|
const devtoolsConfig =
|
||||||
|
process.env.DEBUG_PROD === 'true'
|
||||||
|
? {
|
||||||
|
devtool: 'source-map',
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const configuration: webpack.Configuration = {
|
||||||
|
...devtoolsConfig,
|
||||||
|
|
||||||
|
mode: 'production',
|
||||||
|
|
||||||
|
target: ['web'],
|
||||||
|
|
||||||
|
entry: [path.join(webpackPaths.srcRemotePath, 'index.tsx')],
|
||||||
|
|
||||||
|
output: {
|
||||||
|
path: webpackPaths.distRemotePath,
|
||||||
|
publicPath: './',
|
||||||
|
filename: 'remote.js',
|
||||||
|
library: {
|
||||||
|
type: 'umd',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.s?(a|c)ss$/,
|
||||||
|
use: [
|
||||||
|
MiniCssExtractPlugin.loader,
|
||||||
|
{
|
||||||
|
loader: 'css-loader',
|
||||||
|
options: {
|
||||||
|
modules: true,
|
||||||
|
sourceMap: true,
|
||||||
|
importLoaders: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'sass-loader',
|
||||||
|
],
|
||||||
|
include: /\.module\.s?(c|a)ss$/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.s?(a|c)ss$/,
|
||||||
|
use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
|
||||||
|
exclude: /\.module\.s?(c|a)ss$/,
|
||||||
|
},
|
||||||
|
// Fonts
|
||||||
|
{
|
||||||
|
test: /\.(woff|woff2|eot|ttf|otf)$/i,
|
||||||
|
type: 'asset/resource',
|
||||||
|
},
|
||||||
|
// Images
|
||||||
|
{
|
||||||
|
test: /\.(png|svg|jpg|jpeg|gif)$/i,
|
||||||
|
type: 'asset/resource',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
optimization: {
|
||||||
|
minimize: true,
|
||||||
|
minimizer: [
|
||||||
|
new TerserPlugin({
|
||||||
|
parallel: true,
|
||||||
|
}),
|
||||||
|
new CssMinimizerPlugin(),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
plugins: [
|
||||||
|
/**
|
||||||
|
* Create global constants which can be configured at compile time.
|
||||||
|
*
|
||||||
|
* Useful for allowing different behaviour between development builds and
|
||||||
|
* release builds
|
||||||
|
*
|
||||||
|
* NODE_ENV should be production so that modules do not perform certain
|
||||||
|
* development checks
|
||||||
|
*/
|
||||||
|
new webpack.EnvironmentPlugin({
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
DEBUG_PROD: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
new MiniCssExtractPlugin({
|
||||||
|
filename: 'remote.css',
|
||||||
|
}),
|
||||||
|
|
||||||
|
new BundleAnalyzerPlugin({
|
||||||
|
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
|
||||||
|
}),
|
||||||
|
|
||||||
|
new HtmlWebpackPlugin({
|
||||||
|
filename: 'index.html',
|
||||||
|
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
|
||||||
|
favicon: path.join(webpackPaths.assetsPath, 'icons', 'favicon.ico'),
|
||||||
|
minify: {
|
||||||
|
collapseWhitespace: true,
|
||||||
|
removeAttributeQuotes: true,
|
||||||
|
removeComments: true,
|
||||||
|
},
|
||||||
|
isBrowser: false,
|
||||||
|
isDevelopment: process.env.NODE_ENV !== 'production',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default merge(baseConfig, configuration);
|
||||||
@@ -67,7 +67,10 @@ const configuration: webpack.Configuration = {
|
|||||||
{
|
{
|
||||||
loader: 'css-loader',
|
loader: 'css-loader',
|
||||||
options: {
|
options: {
|
||||||
modules: true,
|
modules: {
|
||||||
|
localIdentName: '[name]__[local]--[hash:base64:5]',
|
||||||
|
exportLocalsConvention: 'camelCaseOnly',
|
||||||
|
},
|
||||||
sourceMap: true,
|
sourceMap: true,
|
||||||
importLoaders: 1,
|
importLoaders: 1,
|
||||||
},
|
},
|
||||||
@@ -168,6 +171,14 @@ const configuration: webpack.Configuration = {
|
|||||||
.on('close', (code: number) => process.exit(code!))
|
.on('close', (code: number) => process.exit(code!))
|
||||||
.on('error', (spawnError) => console.error(spawnError));
|
.on('error', (spawnError) => console.error(spawnError));
|
||||||
|
|
||||||
|
console.log('Starting remote.js builder...');
|
||||||
|
const remoteProcess = spawn('npm', ['run', 'start:remote'], {
|
||||||
|
shell: true,
|
||||||
|
stdio: 'inherit',
|
||||||
|
})
|
||||||
|
.on('close', (code: number) => process.exit(code!))
|
||||||
|
.on('error', (spawnError) => console.error(spawnError));
|
||||||
|
|
||||||
console.log('Starting Main Process...');
|
console.log('Starting Main Process...');
|
||||||
spawn('npm', ['run', 'start:main'], {
|
spawn('npm', ['run', 'start:main'], {
|
||||||
shell: true,
|
shell: true,
|
||||||
@@ -175,6 +186,7 @@ const configuration: webpack.Configuration = {
|
|||||||
})
|
})
|
||||||
.on('close', (code: number) => {
|
.on('close', (code: number) => {
|
||||||
preloadProcess.kill();
|
preloadProcess.kill();
|
||||||
|
remoteProcess.kill();
|
||||||
process.exit(code!);
|
process.exit(code!);
|
||||||
})
|
})
|
||||||
.on('error', (spawnError) => console.error(spawnError));
|
.on('error', (spawnError) => console.error(spawnError));
|
||||||
|
|||||||
@@ -54,7 +54,10 @@ const configuration: webpack.Configuration = {
|
|||||||
{
|
{
|
||||||
loader: 'css-loader',
|
loader: 'css-loader',
|
||||||
options: {
|
options: {
|
||||||
modules: true,
|
modules: {
|
||||||
|
localIdentName: '[name]__[local]--[hash:base64:5]',
|
||||||
|
exportLocalsConvention: 'camelCaseOnly',
|
||||||
|
},
|
||||||
sourceMap: true,
|
sourceMap: true,
|
||||||
importLoaders: 1,
|
importLoaders: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -49,7 +49,10 @@ const configuration: webpack.Configuration = {
|
|||||||
{
|
{
|
||||||
loader: 'css-loader',
|
loader: 'css-loader',
|
||||||
options: {
|
options: {
|
||||||
modules: true,
|
modules: {
|
||||||
|
localIdentName: '[name]__[local]--[hash:base64:5]',
|
||||||
|
exportLocalsConvention: 'camelCaseOnly',
|
||||||
|
},
|
||||||
sourceMap: true,
|
sourceMap: true,
|
||||||
importLoaders: 1,
|
importLoaders: 1,
|
||||||
},
|
},
|
||||||
@@ -103,6 +106,7 @@ const configuration: webpack.Configuration = {
|
|||||||
new HtmlWebpackPlugin({
|
new HtmlWebpackPlugin({
|
||||||
filename: path.join('index.html'),
|
filename: path.join('index.html'),
|
||||||
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
|
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
|
||||||
|
favicon: path.join(webpackPaths.assetsPath, 'icons', 'favicon.ico'),
|
||||||
minify: {
|
minify: {
|
||||||
collapseWhitespace: true,
|
collapseWhitespace: true,
|
||||||
removeAttributeQuotes: true,
|
removeAttributeQuotes: true,
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* Build config for electron renderer process
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import CssMinimizerPlugin from 'css-minimizer-webpack-plugin';
|
||||||
|
import HtmlWebpackPlugin from 'html-webpack-plugin';
|
||||||
|
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
|
||||||
|
import TerserPlugin from 'terser-webpack-plugin';
|
||||||
|
import webpack from 'webpack';
|
||||||
|
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
|
||||||
|
import { merge } from 'webpack-merge';
|
||||||
|
|
||||||
|
import checkNodeEnv from '../scripts/check-node-env';
|
||||||
|
import deleteSourceMaps from '../scripts/delete-source-maps';
|
||||||
|
import baseConfig from './webpack.config.base';
|
||||||
|
import webpackPaths from './webpack.paths';
|
||||||
|
|
||||||
|
checkNodeEnv('production');
|
||||||
|
deleteSourceMaps();
|
||||||
|
|
||||||
|
const devtoolsConfig =
|
||||||
|
process.env.DEBUG_PROD === 'true'
|
||||||
|
? {
|
||||||
|
devtool: 'source-map',
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const configuration: webpack.Configuration = {
|
||||||
|
...devtoolsConfig,
|
||||||
|
|
||||||
|
mode: 'production',
|
||||||
|
|
||||||
|
target: ['web'],
|
||||||
|
|
||||||
|
entry: [path.join(webpackPaths.srcRendererPath, 'index.tsx')],
|
||||||
|
|
||||||
|
output: {
|
||||||
|
path: webpackPaths.distWebPath,
|
||||||
|
publicPath: '/',
|
||||||
|
filename: 'renderer.js',
|
||||||
|
library: {
|
||||||
|
type: 'umd',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.s?(a|c)ss$/,
|
||||||
|
use: [
|
||||||
|
MiniCssExtractPlugin.loader,
|
||||||
|
{
|
||||||
|
loader: 'css-loader',
|
||||||
|
options: {
|
||||||
|
modules: {
|
||||||
|
localIdentName: '[name]__[local]--[hash:base64:5]',
|
||||||
|
exportLocalsConvention: 'camelCaseOnly',
|
||||||
|
},
|
||||||
|
sourceMap: true,
|
||||||
|
importLoaders: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'sass-loader',
|
||||||
|
],
|
||||||
|
include: /\.module\.s?(c|a)ss$/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.s?(a|c)ss$/,
|
||||||
|
use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
|
||||||
|
exclude: /\.module\.s?(c|a)ss$/,
|
||||||
|
},
|
||||||
|
// Fonts
|
||||||
|
{
|
||||||
|
test: /\.(woff|woff2|eot|ttf|otf)$/i,
|
||||||
|
type: 'asset/resource',
|
||||||
|
},
|
||||||
|
// Images
|
||||||
|
{
|
||||||
|
test: /\.(png|svg|jpg|jpeg|gif)$/i,
|
||||||
|
type: 'asset/resource',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
optimization: {
|
||||||
|
minimize: true,
|
||||||
|
minimizer: [
|
||||||
|
new TerserPlugin({
|
||||||
|
parallel: true,
|
||||||
|
}),
|
||||||
|
new CssMinimizerPlugin(),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
plugins: [
|
||||||
|
/**
|
||||||
|
* Create global constants which can be configured at compile time.
|
||||||
|
*
|
||||||
|
* Useful for allowing different behaviour between development builds and
|
||||||
|
* release builds
|
||||||
|
*
|
||||||
|
* NODE_ENV should be production so that modules do not perform certain
|
||||||
|
* development checks
|
||||||
|
*/
|
||||||
|
new webpack.EnvironmentPlugin({
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
DEBUG_PROD: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
new MiniCssExtractPlugin({
|
||||||
|
filename: 'style.css',
|
||||||
|
}),
|
||||||
|
|
||||||
|
new BundleAnalyzerPlugin({
|
||||||
|
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
|
||||||
|
}),
|
||||||
|
|
||||||
|
new HtmlWebpackPlugin({
|
||||||
|
filename: 'index.html',
|
||||||
|
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
|
||||||
|
favicon: path.join(webpackPaths.assetsPath, 'icons', 'favicon.ico'),
|
||||||
|
minify: {
|
||||||
|
collapseWhitespace: true,
|
||||||
|
removeAttributeQuotes: true,
|
||||||
|
removeComments: true,
|
||||||
|
},
|
||||||
|
isBrowser: false,
|
||||||
|
isDevelopment: process.env.NODE_ENV !== 'production',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default merge(baseConfig, configuration);
|
||||||
@@ -5,7 +5,9 @@ const rootPath = path.join(__dirname, '../..');
|
|||||||
const dllPath = path.join(__dirname, '../dll');
|
const dllPath = path.join(__dirname, '../dll');
|
||||||
|
|
||||||
const srcPath = path.join(rootPath, 'src');
|
const srcPath = path.join(rootPath, 'src');
|
||||||
|
const assetsPath = path.join(rootPath, 'assets');
|
||||||
const srcMainPath = path.join(srcPath, 'main');
|
const srcMainPath = path.join(srcPath, 'main');
|
||||||
|
const srcRemotePath = path.join(srcPath, 'remote');
|
||||||
const srcRendererPath = path.join(srcPath, 'renderer');
|
const srcRendererPath = path.join(srcPath, 'renderer');
|
||||||
|
|
||||||
const releasePath = path.join(rootPath, 'release');
|
const releasePath = path.join(rootPath, 'release');
|
||||||
@@ -16,15 +18,19 @@ const srcNodeModulesPath = path.join(srcPath, 'node_modules');
|
|||||||
|
|
||||||
const distPath = path.join(appPath, 'dist');
|
const distPath = path.join(appPath, 'dist');
|
||||||
const distMainPath = path.join(distPath, 'main');
|
const distMainPath = path.join(distPath, 'main');
|
||||||
|
const distRemotePath = path.join(distPath, 'remote');
|
||||||
const distRendererPath = path.join(distPath, 'renderer');
|
const distRendererPath = path.join(distPath, 'renderer');
|
||||||
|
const distWebPath = path.join(distPath, 'web');
|
||||||
|
|
||||||
const buildPath = path.join(releasePath, 'build');
|
const buildPath = path.join(releasePath, 'build');
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
assetsPath,
|
||||||
rootPath,
|
rootPath,
|
||||||
dllPath,
|
dllPath,
|
||||||
srcPath,
|
srcPath,
|
||||||
srcMainPath,
|
srcMainPath,
|
||||||
|
srcRemotePath,
|
||||||
srcRendererPath,
|
srcRendererPath,
|
||||||
releasePath,
|
releasePath,
|
||||||
appPath,
|
appPath,
|
||||||
@@ -33,6 +39,8 @@ export default {
|
|||||||
srcNodeModulesPath,
|
srcNodeModulesPath,
|
||||||
distPath,
|
distPath,
|
||||||
distMainPath,
|
distMainPath,
|
||||||
|
distRemotePath,
|
||||||
distRendererPath,
|
distRendererPath,
|
||||||
|
distWebPath,
|
||||||
buildPath,
|
buildPath,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,20 +5,29 @@ import fs from 'fs';
|
|||||||
import webpackPaths from '../configs/webpack.paths';
|
import webpackPaths from '../configs/webpack.paths';
|
||||||
|
|
||||||
const mainPath = path.join(webpackPaths.distMainPath, 'main.js');
|
const mainPath = path.join(webpackPaths.distMainPath, 'main.js');
|
||||||
|
const remotePath = path.join(webpackPaths.distMainPath, 'remote.js');
|
||||||
const rendererPath = path.join(webpackPaths.distRendererPath, 'renderer.js');
|
const rendererPath = path.join(webpackPaths.distRendererPath, 'renderer.js');
|
||||||
|
|
||||||
if (!fs.existsSync(mainPath)) {
|
if (!fs.existsSync(mainPath)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
chalk.whiteBright.bgRed.bold(
|
chalk.whiteBright.bgRed.bold(
|
||||||
'The main process is not built yet. Build it by running "npm run build:main"'
|
'The main process is not built yet. Build it by running "npm run build:main"',
|
||||||
)
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(remotePath)) {
|
||||||
|
throw new Error(
|
||||||
|
chalk.whiteBright.bgRed.bold(
|
||||||
|
'The remote process is not built yet. Build it by running "npm run build:remote"',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(rendererPath)) {
|
if (!fs.existsSync(rendererPath)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
chalk.whiteBright.bgRed.bold(
|
chalk.whiteBright.bgRed.bold(
|
||||||
'The renderer process is not built yet. Build it by running "npm run build:renderer"'
|
'The renderer process is not built yet. Build it by running "npm run build:renderer"',
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ import webpackPaths from '../configs/webpack.paths';
|
|||||||
|
|
||||||
export default function deleteSourceMaps() {
|
export default function deleteSourceMaps() {
|
||||||
rimraf.sync(path.join(webpackPaths.distMainPath, '*.js.map'));
|
rimraf.sync(path.join(webpackPaths.distMainPath, '*.js.map'));
|
||||||
|
rimraf.sync(path.join(webpackPaths.distRemotePath, '*.js.map'));
|
||||||
rimraf.sync(path.join(webpackPaths.distRendererPath, '*.js.map'));
|
rimraf.sync(path.join(webpackPaths.distRendererPath, '*.js.map'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ module.exports = {
|
|||||||
'@typescript-eslint/no-explicit-any': 'off',
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||||
'@typescript-eslint/no-shadow': ['off'],
|
'@typescript-eslint/no-shadow': ['off'],
|
||||||
|
'@typescript-eslint/no-unused-vars': ['error'],
|
||||||
|
'@typescript-eslint/no-use-before-define': ['error'],
|
||||||
'default-case': 'off',
|
'default-case': 'off',
|
||||||
'import/extensions': 'off',
|
'import/extensions': 'off',
|
||||||
'import/no-absolute-path': 'off',
|
'import/no-absolute-path': 'off',
|
||||||
// A temporary hack related to IDE not resolving correct package.json
|
// A temporary hack related to IDE not resolving correct package.json
|
||||||
'import/no-extraneous-dependencies': 'off',
|
'import/no-extraneous-dependencies': 'off',
|
||||||
|
|
||||||
'import/no-unresolved': 'error',
|
'import/no-unresolved': 'error',
|
||||||
'import/order': [
|
'import/order': [
|
||||||
'error',
|
'error',
|
||||||
@@ -50,8 +51,14 @@ module.exports = {
|
|||||||
'no-console': 'off',
|
'no-console': 'off',
|
||||||
'no-nested-ternary': 'off',
|
'no-nested-ternary': 'off',
|
||||||
'no-restricted-syntax': 'off',
|
'no-restricted-syntax': 'off',
|
||||||
|
'no-shadow': 'off',
|
||||||
'no-underscore-dangle': 'off',
|
'no-underscore-dangle': 'off',
|
||||||
|
'no-unused-vars': 'off',
|
||||||
|
'no-use-before-define': 'off',
|
||||||
'prefer-destructuring': 'off',
|
'prefer-destructuring': 'off',
|
||||||
|
'react/function-component-definition': 'off',
|
||||||
|
'react/jsx-filename-extension': [2, { extensions: ['.js', '.jsx', '.ts', '.tsx'] }],
|
||||||
|
'react/jsx-no-useless-fragment': 'off',
|
||||||
'react/jsx-props-no-spreading': 'off',
|
'react/jsx-props-no-spreading': 'off',
|
||||||
'react/jsx-sort-props': [
|
'react/jsx-sort-props': [
|
||||||
'error',
|
'error',
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
# These are supported funding model platforms
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
ko_fi: jeffvli
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ labels: 'bug'
|
|||||||
|
|
||||||
<!--- Include as many relevant details about the environment you experienced the bug in -->
|
<!--- Include as many relevant details about the environment you experienced the bug in -->
|
||||||
|
|
||||||
- Application version :
|
- Application version (e.g. v0.1.0) :
|
||||||
- Operating System and version :
|
- Operating System and version (e.g. Windows 10) :
|
||||||
|
- Server and version (e.g. Navidrome v0.48.0) :
|
||||||
- Node version (if developing locally) :
|
- Node version (if developing locally) :
|
||||||
|
|||||||
@@ -7,3 +7,5 @@ labels: 'enhancement'
|
|||||||
## What do you want to be added?
|
## What do you want to be added?
|
||||||
|
|
||||||
## Additional context
|
## Additional context
|
||||||
|
|
||||||
|
<!-- Is this a server-specific feature? (e.g. Jellyfin only). -->
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ exemptLabels:
|
|||||||
staleLabel: wontfix
|
staleLabel: wontfix
|
||||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||||
markComment: >
|
markComment: >
|
||||||
This issue has been automatically marked as stale because it has not had
|
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.
|
||||||
recent activity. It will be closed if no further activity occurs. Thank you
|
|
||||||
for your contributions.
|
|
||||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||||
closeComment: false
|
closeComment: false
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Referenced from: https://docs.github.com/en/actions/publishing-packages/publishing-docker-images#introduction
|
||||||
|
name: Publish Docker to GHCR
|
||||||
|
permissions: write-all
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push-image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=pr
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Referenced from: https://docs.github.com/en/actions/publishing-packages/publishing-docker-images#introduction
|
||||||
|
name: Publish Docker to GHCR (Manual)
|
||||||
|
|
||||||
|
on: workflow_dispatch
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push-image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
npm run package
|
|
||||||
npm run lint
|
npm run lint
|
||||||
|
npm run package
|
||||||
npm exec tsc
|
npm exec tsc
|
||||||
npm test
|
npm test
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
"semi": true,
|
"semi": true,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"useTabs": false,
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"files": ["**/*.css", "**/*.scss", "**/*.html"],
|
"files": ["**/*.css", "**/*.scss", "**/*.html"],
|
||||||
"options": {
|
"options": {
|
||||||
"singleQuote": false
|
"singleQuote": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,31 +1,17 @@
|
|||||||
{
|
{
|
||||||
"processors": ["stylelint-processor-styled-components"],
|
"customSyntax": "postcss-styled-syntax",
|
||||||
"customSyntax": "postcss-scss",
|
|
||||||
"extends": [
|
"extends": [
|
||||||
"stylelint-config-standard-scss",
|
"stylelint-config-standard",
|
||||||
"stylelint-config-styled-components",
|
"stylelint-config-styled-components",
|
||||||
"stylelint-config-rational-order"
|
"stylelint-config-recess-order"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"color-function-notation": ["legacy"],
|
|
||||||
"declaration-empty-line-before": null,
|
"declaration-empty-line-before": null,
|
||||||
"order/properties-order": [],
|
|
||||||
"plugin/rational-order": [
|
|
||||||
true,
|
|
||||||
{
|
|
||||||
"border-in-box-model": false,
|
|
||||||
"empty-line-between-groups": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"string-quotes": "single",
|
|
||||||
"declaration-block-no-redundant-longhand-properties": null,
|
"declaration-block-no-redundant-longhand-properties": null,
|
||||||
"selector-class-pattern": null,
|
"selector-class-pattern": null,
|
||||||
"selector-type-case": ["lower", { "ignoreTypes": ["/^\\$\\w+/"] }],
|
"selector-type-case": ["lower", { "ignoreTypes": ["/^\\$\\w+/"] }],
|
||||||
"selector-type-no-unknown": [
|
"selector-type-no-unknown": [true, { "ignoreTypes": ["/-styled-mixin/", "/^\\$\\w+/"] }],
|
||||||
true,
|
"declaration-colon-newline-after": null,
|
||||||
{ "ignoreTypes": ["/-styled-mixin/", "/^\\$\\w+/"] }
|
"property-no-vendor-prefix": null
|
||||||
],
|
|
||||||
"value-keyword-case": ["lower", { "ignoreKeywords": ["dummyValue"] }],
|
|
||||||
"declaration-colon-newline-after": null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
"dbaeumer.vscode-eslint",
|
"dbaeumer.vscode-eslint",
|
||||||
"EditorConfig.EditorConfig",
|
"EditorConfig.EditorConfig",
|
||||||
"stylelint.vscode-stylelint",
|
"stylelint.vscode-stylelint",
|
||||||
"esbenp.prettier-vscode"
|
"esbenp.prettier-vscode",
|
||||||
|
"clinyong.vscode-css-modules",
|
||||||
|
"Huuums.vscode-fast-folder-structure"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,7 @@
|
|||||||
"request": "launch",
|
"request": "launch",
|
||||||
"protocol": "inspector",
|
"protocol": "inspector",
|
||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
"runtimeArgs": [
|
"runtimeArgs": ["run start:main --inspect=5858 --remote-debugging-port=9223"],
|
||||||
"run start:main --inspect=5858 --remote-debugging-port=9223"
|
|
||||||
],
|
|
||||||
"preLaunchTask": "Start Webpack Dev"
|
"preLaunchTask": "Start Webpack Dev"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,14 +10,17 @@
|
|||||||
{ "directory": "./server", "changeProcessCWD": true }
|
{ "directory": "./server", "changeProcessCWD": true }
|
||||||
],
|
],
|
||||||
"typescript.tsserver.experimental.enableProjectDiagnostics": true,
|
"typescript.tsserver.experimental.enableProjectDiagnostics": true,
|
||||||
"editor.tabSize": 2,
|
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll.eslint": true,
|
"source.fixAll.eslint": true,
|
||||||
"source.fixAll.stylelint": false
|
"source.fixAll.stylelint": true,
|
||||||
|
"source.organizeImports": false,
|
||||||
|
"source.formatDocument": true
|
||||||
},
|
},
|
||||||
"css.validate": false,
|
"css.validate": true,
|
||||||
"less.validate": false,
|
"less.validate": false,
|
||||||
"scss.validate": false,
|
"scss.validate": true,
|
||||||
|
"scss.lint.unknownAtRules": "warning",
|
||||||
|
"scss.lint.unknownProperties": "warning",
|
||||||
"javascript.validate.enable": false,
|
"javascript.validate.enable": false,
|
||||||
"javascript.format.enable": false,
|
"javascript.format.enable": false,
|
||||||
"typescript.format.enable": false,
|
"typescript.format.enable": false,
|
||||||
@@ -35,9 +38,35 @@
|
|||||||
"i18n-ally.localesPaths": ["src/i18n", "src/i18n/locales"],
|
"i18n-ally.localesPaths": ["src/i18n", "src/i18n/locales"],
|
||||||
"typescript.tsdk": "node_modules\\typescript\\lib",
|
"typescript.tsdk": "node_modules\\typescript\\lib",
|
||||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||||
"stylelint.validate": ["css", "less", "postcss", "typescript", "typescriptreact", "scss"],
|
"stylelint.validate": ["css", "scss", "typescript", "typescriptreact"],
|
||||||
"typescript.updateImportsOnFileMove.enabled": "always",
|
"typescript.updateImportsOnFileMove.enabled": "always",
|
||||||
"[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
|
"[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
|
||||||
"[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
|
"[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
|
||||||
"typescript.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces": true
|
"typescript.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces": true,
|
||||||
|
"folderTemplates.structures": [
|
||||||
|
{
|
||||||
|
"name": "TypeScript Feature Component With CSS Modules",
|
||||||
|
"omitParentDirectory": true,
|
||||||
|
"structure": [
|
||||||
|
{
|
||||||
|
"fileName": "<FTName | kebabcase>.tsx",
|
||||||
|
"template": "Functional Component with CSS Modules"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fileName": "<FTName | kebabcase>.module.scss"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"folderTemplates.fileTemplates": {
|
||||||
|
"Functional Component with CSS Modules": [
|
||||||
|
"import styles from './<FTName | kebabcase>.module.scss';",
|
||||||
|
"",
|
||||||
|
"interface <FTName | pascalcase>Props {}",
|
||||||
|
"",
|
||||||
|
"export const <FTName | pascalcase> = ({}: <FTName | pascalcase>Props) => {",
|
||||||
|
" return <div></div>;",
|
||||||
|
"};"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# --- Builder stage
|
||||||
|
FROM node:18-alpine as builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . /app
|
||||||
|
|
||||||
|
# Scripts include electron-specific dependencies, which we don't need
|
||||||
|
RUN npm install --legacy-peer-deps --ignore-scripts
|
||||||
|
RUN npm run build:web
|
||||||
|
|
||||||
|
# --- Production stage
|
||||||
|
FROM nginx:alpine-slim
|
||||||
|
|
||||||
|
COPY --from=builder /app/release/app/dist/web /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 9180
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<img src="assets/icons/icon.png" alt="logo" title="feishin" align="right" height="60px" />
|
||||||
|
|
||||||
# Feishin
|
# Feishin
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -34,27 +36,58 @@ Rewrite of [Sonixd](https://github.com/jeffvli/sonixd).
|
|||||||
- [x] Modern UI
|
- [x] Modern UI
|
||||||
- [x] Scrobble playback to your server
|
- [x] Scrobble playback to your server
|
||||||
- [x] Smart playlist editor (Navidrome)
|
- [x] Smart playlist editor (Navidrome)
|
||||||
|
- [x] Synchronized and unsynchronized lyrics support
|
||||||
- [ ] [Request a feature](https://github.com/jeffvli/feishin/issues) or [view taskboard](https://github.com/users/jeffvli/projects/5/views/1)
|
- [ ] [Request a feature](https://github.com/jeffvli/feishin/issues) or [view taskboard](https://github.com/users/jeffvli/projects/5/views/1)
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_home.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_home.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_artist_detail.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_artist_detail.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_detail.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_detail.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_smart_playlist.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_smart_playlist.png" width="49.5%"/></a>
|
<a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_full_screen_player.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_full_screen_player.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_artist_detail.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_artist_detail.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_detail.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_detail.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_smart_playlist.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_smart_playlist.png" width="49.5%"/></a>
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
Download the [latest desktop client](https://github.com/jeffvli/feishin/releases).
|
### Desktop (recommended)
|
||||||
|
|
||||||
|
Download the [latest desktop client](https://github.com/jeffvli/feishin/releases). The desktop client is the recommended way to use Feishin. It supports both the MPV and web player backends, as well as includes built-in fetching for lyrics.
|
||||||
|
|
||||||
|
If you're using a device running macOS 12 (Monterey) or higher, [check here](https://github.com/jeffvli/feishin/issues/104#issuecomment-1553914730) for instructions on how to remove the app from quarantine.
|
||||||
|
|
||||||
|
### Web and Docker
|
||||||
|
|
||||||
|
Visit [https://feishin.vercel.app](https://feishin.vercel.app) to use the hosted web version of Feishin. The web client only supports the web player backend.
|
||||||
|
|
||||||
|
Feishin is also available as a Docker image. The images are hosted via `ghcr.io` and are available to view [here](https://github.com/jeffvli/feishin/pkgs/container/feishin). You can run the container using the following commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run the latest version
|
||||||
|
docker run --name feishin --port 9180:9180 ghcr.io/jeffvli/feishin:latest
|
||||||
|
|
||||||
|
# Build the image locally
|
||||||
|
docker build -t feishin .
|
||||||
|
docker run --name feishin --port 9180:9180 feishin
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
1. Upon startup you will be greeted with a prompt to select the path to your MPV binary. If you do not have MPV installed, you can download it [here](https://mpv.io/installation/) or install it using any package manager supported by your OS. After inputting the path, restart the app.
|
||||||
|
|
||||||
|
2. After restarting the app, you will be prompted to select a server. Click the `Open menu` button and select `Manage servers`. Click the `Add server` button in the popup and fill out all applicable details. You will need to enter the full URL to your server, including the protocol and port if applicable (e.g. `https://navidrome.my-server.com` or `http://192.168.0.1:4533`).
|
||||||
|
|
||||||
|
- **Navidrome** - For the best experience, select "Save password" when creating the server and configure the `SessionTimeout` setting in your Navidrome config to a larger value (e.g. 72h).
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
|
### MPV is either not working or is rapidly switching between pause/play states
|
||||||
|
|
||||||
|
First thing to do is check that your MPV binary path is correct. Navigate to the settings page and re-set the path and restart the app. If your issue still isn't resolved, try reinstalling MPV. Known working versions include `v0.35.x` and `v0.36.x`. `v0.34.x` is a known broken version.
|
||||||
|
|
||||||
### What music servers does Feishin support?
|
### What music servers does Feishin support?
|
||||||
|
|
||||||
Feishin supports any music server that implements a [Navidrome](https://www.navidrome.org/) or [Jellyfin](https://jellyfin.org/) API. **Subsonic API is not currently supported**. This will likely be added in [later when the new Subsonic API is decided on](https://support.symfonium.app/t/subsonic-servers-participation/1233).
|
Feishin supports any music server that implements a [Navidrome](https://www.navidrome.org/) or [Jellyfin](https://jellyfin.org/) API. **Subsonic API is not currently supported**. This will likely be added in [later when the new Subsonic API is decided on](https://support.symfonium.app/t/subsonic-servers-participation/1233).
|
||||||
|
|
||||||
- [Navidrome](https://github.com/navidrome/navidrome)
|
- [Navidrome](https://github.com/navidrome/navidrome)
|
||||||
- [Jellyfin](https://github.com/jellyfin/jellyfin)
|
- [Jellyfin](https://github.com/jellyfin/jellyfin)
|
||||||
- ~~[Gonic](https://github.com/sentriz/gonic)~~
|
- [Funkwhale](https://funkwhale.audio/) - TBD
|
||||||
- ~~[Astiga](https://asti.ga/)~~
|
- Subsonic-compatible servers - TBD
|
||||||
- ~~[Supysonic](https://github.com/spl0k/supysonic)~~
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 465 KiB |
@@ -0,0 +1,20 @@
|
|||||||
|
server {
|
||||||
|
listen 9180;
|
||||||
|
sendfile on;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_http_version 1.1;
|
||||||
|
gzip_disable "MSIE [1-6]\.";
|
||||||
|
gzip_min_length 256;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied expired no-cache no-store private auth;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript;
|
||||||
|
gzip_comp_level 9;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html =404;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,14 +2,18 @@
|
|||||||
"name": "feishin",
|
"name": "feishin",
|
||||||
"productName": "Feishin",
|
"productName": "Feishin",
|
||||||
"description": "Feishin music server",
|
"description": "Feishin music server",
|
||||||
"version": "0.0.1-alpha6",
|
"version": "0.4.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\"",
|
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\" \"npm run build:remote\"",
|
||||||
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
|
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
|
||||||
|
"build:remote": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.remote.prod.ts",
|
||||||
"build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.prod.ts",
|
"build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.prod.ts",
|
||||||
|
"build:web": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.web.prod.ts",
|
||||||
|
"build:docker": "npm run build:web && docker build -t jeffvli/feishin .",
|
||||||
"rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app",
|
"rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app",
|
||||||
"lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx",
|
"lint": "concurrently \"npm run lint:code\" \"npm run lint:styles\"",
|
||||||
"lint:styles": "npx stylelint **/*.tsx",
|
"lint:code": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx --fix",
|
||||||
|
"lint:styles": "npx stylelint **/*.tsx --fix",
|
||||||
"package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never",
|
"package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never",
|
||||||
"package:pr": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --win --mac --linux",
|
"package:pr": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --win --mac --linux",
|
||||||
"package:dev": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --dir",
|
"package:dev": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --dir",
|
||||||
@@ -17,6 +21,7 @@
|
|||||||
"start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run start:renderer",
|
"start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run start:renderer",
|
||||||
"start:main": "cross-env NODE_ENV=development electron -r ts-node/register/transpile-only ./src/main/main.ts",
|
"start:main": "cross-env NODE_ENV=development electron -r ts-node/register/transpile-only ./src/main/main.ts",
|
||||||
"start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts",
|
"start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts",
|
||||||
|
"start:remote": "cross-env NODE_ENV=developemnt TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.remote.dev.ts",
|
||||||
"start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts",
|
"start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts",
|
||||||
"start:web": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.web.ts",
|
"start:web": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.web.ts",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
@@ -51,7 +56,7 @@
|
|||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
"afterSign": ".erb/scripts/notarize.js",
|
"afterSign": ".erb/scripts/notarize.js",
|
||||||
"electronVersion": "22.3.1",
|
"electronVersion": "25.8.1",
|
||||||
"mac": {
|
"mac": {
|
||||||
"target": {
|
"target": {
|
||||||
"target": "default",
|
"target": "default",
|
||||||
@@ -60,6 +65,7 @@
|
|||||||
"x64"
|
"x64"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"icon": "assets/icons/icon.icns",
|
||||||
"type": "distribution",
|
"type": "distribution",
|
||||||
"hardenedRuntime": true,
|
"hardenedRuntime": true,
|
||||||
"entitlements": "assets/entitlements.mac.plist",
|
"entitlements": "assets/entitlements.mac.plist",
|
||||||
@@ -84,14 +90,15 @@
|
|||||||
"target": [
|
"target": [
|
||||||
"nsis",
|
"nsis",
|
||||||
"zip"
|
"zip"
|
||||||
]
|
],
|
||||||
|
"icon": "assets/icons/icon.ico"
|
||||||
},
|
},
|
||||||
"linux": {
|
"linux": {
|
||||||
"target": [
|
"target": [
|
||||||
"AppImage",
|
"AppImage",
|
||||||
"tar.xz"
|
"tar.xz"
|
||||||
],
|
],
|
||||||
"icon": "assets/icons/placeholder.png",
|
"icon": "assets/icons/icon.png",
|
||||||
"category": "Development"
|
"category": "Development"
|
||||||
},
|
},
|
||||||
"directories": {
|
"directories": {
|
||||||
@@ -190,8 +197,8 @@
|
|||||||
"css-loader": "^6.7.1",
|
"css-loader": "^6.7.1",
|
||||||
"css-minimizer-webpack-plugin": "^3.4.1",
|
"css-minimizer-webpack-plugin": "^3.4.1",
|
||||||
"detect-port": "^1.3.0",
|
"detect-port": "^1.3.0",
|
||||||
"electron": "^22.3.1",
|
"electron": "^25.8.1",
|
||||||
"electron-builder": "^24.0.0-alpha.13",
|
"electron-builder": "^24.6.3",
|
||||||
"electron-devtools-installer": "^3.2.0",
|
"electron-devtools-installer": "^3.2.0",
|
||||||
"electron-notarize": "^1.2.1",
|
"electron-notarize": "^1.2.1",
|
||||||
"electronmon": "^2.0.2",
|
"electronmon": "^2.0.2",
|
||||||
@@ -218,6 +225,7 @@
|
|||||||
"lint-staged": "^12.3.7",
|
"lint-staged": "^12.3.7",
|
||||||
"mini-css-extract-plugin": "^2.6.0",
|
"mini-css-extract-plugin": "^2.6.0",
|
||||||
"postcss-scss": "^4.0.4",
|
"postcss-scss": "^4.0.4",
|
||||||
|
"postcss-styled-syntax": "^0.5.0",
|
||||||
"postcss-syntax": "^0.36.2",
|
"postcss-syntax": "^0.36.2",
|
||||||
"prettier": "^2.6.2",
|
"prettier": "^2.6.2",
|
||||||
"react-refresh": "^0.12.0",
|
"react-refresh": "^0.12.0",
|
||||||
@@ -227,19 +235,19 @@
|
|||||||
"sass": "^1.49.11",
|
"sass": "^1.49.11",
|
||||||
"sass-loader": "^12.6.0",
|
"sass-loader": "^12.6.0",
|
||||||
"style-loader": "^3.3.1",
|
"style-loader": "^3.3.1",
|
||||||
"stylelint": "^14.9.1",
|
"stylelint": "^15.10.3",
|
||||||
"stylelint-config-rational-order": "^0.1.2",
|
"stylelint-config-css-modules": "^4.3.0",
|
||||||
|
"stylelint-config-recess-order": "^4.3.0",
|
||||||
|
"stylelint-config-standard": "^34.0.0",
|
||||||
"stylelint-config-standard-scss": "^4.0.0",
|
"stylelint-config-standard-scss": "^4.0.0",
|
||||||
"stylelint-config-styled-components": "^0.1.1",
|
"stylelint-config-styled-components": "^0.1.1",
|
||||||
"stylelint-order": "^5.0.0",
|
|
||||||
"stylelint-processor-styled-components": "^1.10.0",
|
|
||||||
"terser-webpack-plugin": "^5.3.1",
|
"terser-webpack-plugin": "^5.3.1",
|
||||||
"ts-jest": "^27.1.4",
|
"ts-jest": "^27.1.4",
|
||||||
"ts-loader": "^9.2.8",
|
"ts-loader": "^9.2.8",
|
||||||
"ts-node": "^10.7.0",
|
"ts-node": "^10.7.0",
|
||||||
"tsconfig-paths-webpack-plugin": "^4.0.0",
|
"tsconfig-paths-webpack-plugin": "^4.0.0",
|
||||||
"typescript": "^4.8.4",
|
"typescript": "^5.2.2",
|
||||||
"typescript-plugin-styled-components": "^2.0.0",
|
"typescript-plugin-styled-components": "^3.0.0",
|
||||||
"url-loader": "^4.1.1",
|
"url-loader": "^4.1.1",
|
||||||
"webpack": "^5.71.0",
|
"webpack": "^5.71.0",
|
||||||
"webpack-bundle-analyzer": "^4.5.0",
|
"webpack-bundle-analyzer": "^4.5.0",
|
||||||
@@ -254,55 +262,62 @@
|
|||||||
"@ag-grid-community/react": "^28.2.1",
|
"@ag-grid-community/react": "^28.2.1",
|
||||||
"@ag-grid-community/styles": "^28.2.1",
|
"@ag-grid-community/styles": "^28.2.1",
|
||||||
"@emotion/react": "^11.10.4",
|
"@emotion/react": "^11.10.4",
|
||||||
"@mantine/core": "^6.0.8",
|
"@mantine/core": "^6.0.17",
|
||||||
"@mantine/dates": "^6.0.8",
|
"@mantine/dates": "^6.0.17",
|
||||||
"@mantine/dropzone": "^6.0.8",
|
"@mantine/form": "^6.0.17",
|
||||||
"@mantine/form": "^6.0.8",
|
"@mantine/hooks": "^6.0.17",
|
||||||
"@mantine/hooks": "^6.0.8",
|
"@mantine/modals": "^6.0.17",
|
||||||
"@mantine/modals": "^6.0.8",
|
"@mantine/notifications": "^6.0.17",
|
||||||
"@mantine/notifications": "^6.0.8",
|
"@mantine/utils": "^6.0.17",
|
||||||
"@mantine/utils": "^6.0.8",
|
"@tanstack/react-query": "^4.32.1",
|
||||||
"@tanstack/react-query": "^4.24.4",
|
"@tanstack/react-query-devtools": "^4.32.1",
|
||||||
"@tanstack/react-query-devtools": "^4.24.4",
|
"@tanstack/react-query-persist-client": "^4.32.1",
|
||||||
"@ts-rest/core": "^3.19.2",
|
"@ts-rest/core": "^3.23.0",
|
||||||
"axios": "^1.3.6",
|
"axios": "^1.4.0",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"cmdk": "^0.2.0",
|
||||||
"dayjs": "^1.11.6",
|
"dayjs": "^1.11.6",
|
||||||
"electron-debug": "^3.2.0",
|
"electron-debug": "^3.2.0",
|
||||||
"electron-localshortcut": "^3.2.1",
|
"electron-localshortcut": "^3.2.1",
|
||||||
"electron-log": "^4.4.6",
|
"electron-log": "^4.4.6",
|
||||||
"electron-store": "^8.1.0",
|
"electron-store": "^8.1.0",
|
||||||
"electron-updater": "^4.6.5",
|
"electron-updater": "^4.6.5",
|
||||||
"fast-average-color": "^9.2.0",
|
"fast-average-color": "^9.3.0",
|
||||||
"format-duration": "^2.0.0",
|
"format-duration": "^2.0.0",
|
||||||
"framer-motion": "^8.1.3",
|
"framer-motion": "^10.13.0",
|
||||||
|
"fuse.js": "^6.6.2",
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
"i18next": "^21.6.16",
|
"i18next": "^21.6.16",
|
||||||
"immer": "^9.0.15",
|
"idb-keyval": "^6.2.1",
|
||||||
"is-electron": "^2.2.1",
|
"immer": "^9.0.21",
|
||||||
|
"is-electron": "^2.2.2",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"md5": "^2.3.0",
|
"md5": "^2.3.0",
|
||||||
"memoize-one": "^6.0.0",
|
"memoize-one": "^6.0.0",
|
||||||
"nanoid": "^3.3.3",
|
"nanoid": "^3.3.3",
|
||||||
"net": "^1.0.2",
|
"net": "^1.0.2",
|
||||||
"node-mpv": "^2.0.0-beta.2",
|
"node-mpv": "github:jeffvli/Node-MPV",
|
||||||
|
"overlayscrollbars": "^2.2.1",
|
||||||
|
"overlayscrollbars-react": "^0.5.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-error-boundary": "^3.1.4",
|
"react-error-boundary": "^3.1.4",
|
||||||
"react-i18next": "^11.16.7",
|
"react-i18next": "^11.16.7",
|
||||||
"react-icons": "^4.7.1",
|
"react-icons": "^4.10.1",
|
||||||
"react-player": "^2.11.0",
|
"react-player": "^2.11.0",
|
||||||
"react-router": "^6.5.0",
|
"react-router": "^6.5.0",
|
||||||
"react-router-dom": "^6.5.0",
|
"react-router-dom": "^6.5.0",
|
||||||
"react-simple-img": "^3.0.0",
|
"react-simple-img": "^3.0.0",
|
||||||
"react-virtualized-auto-sizer": "^1.0.6",
|
"react-virtualized-auto-sizer": "^1.0.17",
|
||||||
"react-window": "^1.8.8",
|
"react-window": "^1.8.9",
|
||||||
"react-window-infinite-loader": "^1.0.8",
|
"react-window-infinite-loader": "^1.0.9",
|
||||||
"styled-components": "^5.3.6",
|
"styled-components": "^6.0.8",
|
||||||
"zod": "^3.19.1",
|
"swiper": "^9.3.1",
|
||||||
"zustand": "^4.1.4"
|
"zod": "^3.21.4",
|
||||||
|
"zustand": "^4.3.9"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"styled-components": "^5"
|
"styled-components": "^6"
|
||||||
},
|
},
|
||||||
"devEngines": {
|
"devEngines": {
|
||||||
"node": ">=14.x",
|
"node": ">=14.x",
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "feishin",
|
"name": "feishin",
|
||||||
"version": "0.0.1-alpha6",
|
"version": "0.4.0",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "feishin",
|
"name": "feishin",
|
||||||
"version": "0.0.1-alpha6",
|
"version": "0.4.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mpris-service": "^2.1.2"
|
"cheerio": "^1.0.0-rc.12",
|
||||||
|
"mpris-service": "^2.1.2",
|
||||||
|
"ws": "^8.13.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "22.3.1"
|
"electron": "25.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@electron/get": {
|
"node_modules/@electron/get": {
|
||||||
@@ -97,9 +99,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "16.18.16",
|
"version": "18.16.19",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.16.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.19.tgz",
|
||||||
"integrity": "sha512-ZOzvDRWp8dCVBmgnkIqYCArgdFOO9YzocZp8Ra25N/RStKiWvMOXHMz+GjSeVNe5TstaTmTWPucGJkDw0XXJWA==",
|
"integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/responselike": {
|
"node_modules/@types/responselike": {
|
||||||
@@ -147,6 +149,11 @@
|
|||||||
"file-uri-to-path": "1.0.0"
|
"file-uri-to-path": "1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/boolbase": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="
|
||||||
|
},
|
||||||
"node_modules/boolean": {
|
"node_modules/boolean": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
|
||||||
@@ -207,6 +214,42 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cheerio": {
|
||||||
|
"version": "1.0.0-rc.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz",
|
||||||
|
"integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"cheerio-select": "^2.1.0",
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.0.1",
|
||||||
|
"htmlparser2": "^8.0.1",
|
||||||
|
"parse5": "^7.0.0",
|
||||||
|
"parse5-htmlparser2-tree-adapter": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cheerio-select": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0",
|
||||||
|
"css-select": "^5.1.0",
|
||||||
|
"css-what": "^6.1.0",
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/clone-response": {
|
"node_modules/clone-response": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
|
||||||
@@ -219,6 +262,32 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-select": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0",
|
||||||
|
"css-what": "^6.1.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"domutils": "^3.0.1",
|
||||||
|
"nth-check": "^2.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/css-what": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dbus-next": {
|
"node_modules/dbus-next": {
|
||||||
"version": "0.9.2",
|
"version": "0.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/dbus-next/-/dbus-next-0.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/dbus-next/-/dbus-next-0.9.2.tgz",
|
||||||
@@ -327,20 +396,71 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-serializer": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"entities": "^4.2.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domelementtype": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/domhandler": {
|
||||||
|
"version": "5.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||||
|
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domutils": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
|
||||||
|
"dependencies": {
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/duplexer": {
|
"node_modules/duplexer": {
|
||||||
"version": "0.1.2",
|
"version": "0.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
|
||||||
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
|
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
|
||||||
},
|
},
|
||||||
"node_modules/electron": {
|
"node_modules/electron": {
|
||||||
"version": "22.3.1",
|
"version": "25.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/electron/-/electron-22.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/electron/-/electron-25.3.0.tgz",
|
||||||
"integrity": "sha512-iDltL9j12bINK3aOp8ZoGq4NFBFjJhw1AYHelbWj93XUCAIT4fdA+PRsq0aaTHg3bthLLlLRvIZVgNsZPqWcqg==",
|
"integrity": "sha512-cyqotxN+AroP5h2IxUsJsmehYwP5LrFAOO7O7k9tILME3Sa1/POAg3shrhx4XEnaAMyMqMLxzGvkzCVxzEErnA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron/get": "^2.0.0",
|
"@electron/get": "^2.0.0",
|
||||||
"@types/node": "^16.11.26",
|
"@types/node": "^18.11.18",
|
||||||
"extract-zip": "^2.0.1"
|
"extract-zip": "^2.0.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -359,6 +479,17 @@
|
|||||||
"once": "^1.4.0"
|
"once": "^1.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/env-paths": {
|
"node_modules/env-paths": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||||
@@ -633,6 +764,24 @@
|
|||||||
"hexy": "bin/hexy_cmd.js"
|
"hexy": "bin/hexy_cmd.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/htmlparser2": {
|
||||||
|
"version": "8.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||||
|
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.0.1",
|
||||||
|
"entities": "^4.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-cache-semantics": {
|
"node_modules/http-cache-semantics": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
|
||||||
@@ -820,6 +969,17 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nth-check": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-is": {
|
"node_modules/object-is": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz",
|
||||||
@@ -861,6 +1021,29 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parse5": {
|
||||||
|
"version": "7.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz",
|
||||||
|
"integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==",
|
||||||
|
"dependencies": {
|
||||||
|
"entities": "^4.4.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5-htmlparser2-tree-adapter": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==",
|
||||||
|
"dependencies": {
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"parse5": "^7.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pause-stream": {
|
"node_modules/pause-stream": {
|
||||||
"version": "0.0.11",
|
"version": "0.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz",
|
||||||
@@ -1102,6 +1285,26 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz",
|
||||||
|
"integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xml2js": {
|
"node_modules/xml2js": {
|
||||||
"version": "0.4.23",
|
"version": "0.4.23",
|
||||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
||||||
@@ -1205,9 +1408,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@types/node": {
|
"@types/node": {
|
||||||
"version": "16.18.16",
|
"version": "18.16.19",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.16.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.19.tgz",
|
||||||
"integrity": "sha512-ZOzvDRWp8dCVBmgnkIqYCArgdFOO9YzocZp8Ra25N/RStKiWvMOXHMz+GjSeVNe5TstaTmTWPucGJkDw0XXJWA==",
|
"integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"@types/responselike": {
|
"@types/responselike": {
|
||||||
@@ -1248,6 +1451,11 @@
|
|||||||
"file-uri-to-path": "1.0.0"
|
"file-uri-to-path": "1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"boolbase": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="
|
||||||
|
},
|
||||||
"boolean": {
|
"boolean": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
|
||||||
@@ -1296,6 +1504,33 @@
|
|||||||
"get-intrinsic": "^1.0.2"
|
"get-intrinsic": "^1.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"cheerio": {
|
||||||
|
"version": "1.0.0-rc.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz",
|
||||||
|
"integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==",
|
||||||
|
"requires": {
|
||||||
|
"cheerio-select": "^2.1.0",
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.0.1",
|
||||||
|
"htmlparser2": "^8.0.1",
|
||||||
|
"parse5": "^7.0.0",
|
||||||
|
"parse5-htmlparser2-tree-adapter": "^7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cheerio-select": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
|
||||||
|
"requires": {
|
||||||
|
"boolbase": "^1.0.0",
|
||||||
|
"css-select": "^5.1.0",
|
||||||
|
"css-what": "^6.1.0",
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"clone-response": {
|
"clone-response": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
|
||||||
@@ -1305,6 +1540,23 @@
|
|||||||
"mimic-response": "^1.0.0"
|
"mimic-response": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"css-select": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==",
|
||||||
|
"requires": {
|
||||||
|
"boolbase": "^1.0.0",
|
||||||
|
"css-what": "^6.1.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"domutils": "^3.0.1",
|
||||||
|
"nth-check": "^2.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"css-what": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw=="
|
||||||
|
},
|
||||||
"dbus-next": {
|
"dbus-next": {
|
||||||
"version": "0.9.2",
|
"version": "0.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/dbus-next/-/dbus-next-0.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/dbus-next/-/dbus-next-0.9.2.tgz",
|
||||||
@@ -1381,19 +1633,52 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"dom-serializer": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||||
|
"requires": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"entities": "^4.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"domelementtype": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="
|
||||||
|
},
|
||||||
|
"domhandler": {
|
||||||
|
"version": "5.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||||
|
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||||
|
"requires": {
|
||||||
|
"domelementtype": "^2.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"domutils": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
|
||||||
|
"requires": {
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"duplexer": {
|
"duplexer": {
|
||||||
"version": "0.1.2",
|
"version": "0.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
|
||||||
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
|
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
|
||||||
},
|
},
|
||||||
"electron": {
|
"electron": {
|
||||||
"version": "22.3.1",
|
"version": "25.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/electron/-/electron-22.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/electron/-/electron-25.3.0.tgz",
|
||||||
"integrity": "sha512-iDltL9j12bINK3aOp8ZoGq4NFBFjJhw1AYHelbWj93XUCAIT4fdA+PRsq0aaTHg3bthLLlLRvIZVgNsZPqWcqg==",
|
"integrity": "sha512-cyqotxN+AroP5h2IxUsJsmehYwP5LrFAOO7O7k9tILME3Sa1/POAg3shrhx4XEnaAMyMqMLxzGvkzCVxzEErnA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"@electron/get": "^2.0.0",
|
"@electron/get": "^2.0.0",
|
||||||
"@types/node": "^16.11.26",
|
"@types/node": "^18.11.18",
|
||||||
"extract-zip": "^2.0.1"
|
"extract-zip": "^2.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1406,6 +1691,11 @@
|
|||||||
"once": "^1.4.0"
|
"once": "^1.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="
|
||||||
|
},
|
||||||
"env-paths": {
|
"env-paths": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||||
@@ -1608,6 +1898,17 @@
|
|||||||
"resolved": "https://registry.npmjs.org/hexy/-/hexy-0.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/hexy/-/hexy-0.2.11.tgz",
|
||||||
"integrity": "sha512-ciq6hFsSG/Bpt2DmrZJtv+56zpPdnq+NQ4ijEFrveKN0ZG1mhl/LdT1NQZ9se6ty1fACcI4d4vYqC9v8EYpH2A=="
|
"integrity": "sha512-ciq6hFsSG/Bpt2DmrZJtv+56zpPdnq+NQ4ijEFrveKN0ZG1mhl/LdT1NQZ9se6ty1fACcI4d4vYqC9v8EYpH2A=="
|
||||||
},
|
},
|
||||||
|
"htmlparser2": {
|
||||||
|
"version": "8.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||||
|
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
||||||
|
"requires": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.0.1",
|
||||||
|
"entities": "^4.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"http-cache-semantics": {
|
"http-cache-semantics": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
|
||||||
@@ -1756,6 +2057,14 @@
|
|||||||
"integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==",
|
"integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"nth-check": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||||
|
"requires": {
|
||||||
|
"boolbase": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"object-is": {
|
"object-is": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz",
|
||||||
@@ -1785,6 +2094,23 @@
|
|||||||
"integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==",
|
"integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"parse5": {
|
||||||
|
"version": "7.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz",
|
||||||
|
"integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==",
|
||||||
|
"requires": {
|
||||||
|
"entities": "^4.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parse5-htmlparser2-tree-adapter": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==",
|
||||||
|
"requires": {
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"parse5": "^7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"pause-stream": {
|
"pause-stream": {
|
||||||
"version": "0.0.11",
|
"version": "0.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz",
|
||||||
@@ -1964,6 +2290,12 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"ws": {
|
||||||
|
"version": "8.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz",
|
||||||
|
"integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==",
|
||||||
|
"requires": {}
|
||||||
|
},
|
||||||
"xml2js": {
|
"xml2js": {
|
||||||
"version": "0.4.23",
|
"version": "0.4.23",
|
||||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "feishin",
|
"name": "feishin",
|
||||||
"version": "0.0.1-alpha6",
|
"version": "0.4.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "./dist/main/main.js",
|
"main": "./dist/main/main.js",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -13,10 +13,12 @@
|
|||||||
"postinstall": "npm run electron-rebuild && npm run link-modules"
|
"postinstall": "npm run electron-rebuild && npm run link-modules"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mpris-service": "^2.1.2"
|
"cheerio": "^1.0.0-rc.12",
|
||||||
|
"mpris-service": "^2.1.2",
|
||||||
|
"ws": "^8.13.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "22.3.1"
|
"electron": "25.3.0"
|
||||||
},
|
},
|
||||||
"license": "GPL-3.0"
|
"license": "GPL-3.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
|
import './lyrics';
|
||||||
import './player';
|
import './player';
|
||||||
|
import './remote';
|
||||||
import './settings';
|
import './settings';
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import axios, { AxiosResponse } from 'axios';
|
||||||
|
import { load } from 'cheerio';
|
||||||
|
import { orderSearchResults } from './shared';
|
||||||
|
import {
|
||||||
|
LyricSource,
|
||||||
|
InternetProviderLyricResponse,
|
||||||
|
InternetProviderLyricSearchResponse,
|
||||||
|
LyricSearchQuery,
|
||||||
|
} from '../../../../renderer/api/types';
|
||||||
|
|
||||||
|
const SEARCH_URL = 'https://genius.com/api/search/song';
|
||||||
|
|
||||||
|
// Adapted from https://github.com/NyaomiDEV/Sunamu/blob/master/src/main/lyricproviders/genius.ts
|
||||||
|
|
||||||
|
export interface GeniusResponse {
|
||||||
|
meta: Meta;
|
||||||
|
response: Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Meta {
|
||||||
|
status: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Response {
|
||||||
|
next_page: number;
|
||||||
|
sections: Section[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Section {
|
||||||
|
hits: Hit[];
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Hit {
|
||||||
|
highlights: any[];
|
||||||
|
index: string;
|
||||||
|
result: Result;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Result {
|
||||||
|
_type: string;
|
||||||
|
annotation_count: number;
|
||||||
|
api_path: string;
|
||||||
|
artist_names: string;
|
||||||
|
featured_artists: any[];
|
||||||
|
full_title: string;
|
||||||
|
header_image_thumbnail_url: string;
|
||||||
|
header_image_url: string;
|
||||||
|
id: number;
|
||||||
|
instrumental: boolean;
|
||||||
|
language: string;
|
||||||
|
lyrics_owner_id: number;
|
||||||
|
lyrics_state: string;
|
||||||
|
lyrics_updated_at: number;
|
||||||
|
path: string;
|
||||||
|
primary_artist: PrimaryArtist;
|
||||||
|
pyongs_count: null;
|
||||||
|
relationships_index_url: string;
|
||||||
|
release_date_components: ReleaseDateComponents;
|
||||||
|
release_date_for_display: string;
|
||||||
|
release_date_with_abbreviated_month_for_display: string;
|
||||||
|
song_art_image_thumbnail_url: string;
|
||||||
|
song_art_image_url: string;
|
||||||
|
stats: Stats;
|
||||||
|
title: string;
|
||||||
|
title_with_featured: string;
|
||||||
|
updated_by_human_at: number;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrimaryArtist {
|
||||||
|
_type: string;
|
||||||
|
api_path: string;
|
||||||
|
header_image_url: string;
|
||||||
|
id: number;
|
||||||
|
image_url: string;
|
||||||
|
index_character: string;
|
||||||
|
is_meme_verified: boolean;
|
||||||
|
is_verified: boolean;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReleaseDateComponents {
|
||||||
|
day: number;
|
||||||
|
month: number;
|
||||||
|
year: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Stats {
|
||||||
|
hot: boolean;
|
||||||
|
unreviewed_annotations: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSearchResults(
|
||||||
|
params: LyricSearchQuery,
|
||||||
|
): Promise<InternetProviderLyricSearchResponse[] | null> {
|
||||||
|
let result: AxiosResponse<GeniusResponse>;
|
||||||
|
|
||||||
|
const searchQuery = [params.artist, params.name].join(' ');
|
||||||
|
|
||||||
|
if (!searchQuery) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
result = await axios.get(SEARCH_URL, {
|
||||||
|
params: {
|
||||||
|
per_page: '5',
|
||||||
|
q: searchQuery,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Genius search request got an error!', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawSongsResult = result.data.response?.sections?.[0]?.hits?.map((hit) => hit.result);
|
||||||
|
|
||||||
|
if (!rawSongsResult) return null;
|
||||||
|
|
||||||
|
const songResults: InternetProviderLyricSearchResponse[] = rawSongsResult.map((song) => {
|
||||||
|
return {
|
||||||
|
artist: song.artist_names,
|
||||||
|
id: song.url,
|
||||||
|
name: song.full_title,
|
||||||
|
source: LyricSource.GENIUS,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return orderSearchResults({ params, results: songResults });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSongId(
|
||||||
|
params: LyricSearchQuery,
|
||||||
|
): Promise<Omit<InternetProviderLyricResponse, 'lyrics'> | null> {
|
||||||
|
let result: AxiosResponse<GeniusResponse>;
|
||||||
|
try {
|
||||||
|
result = await axios.get(SEARCH_URL, {
|
||||||
|
params: {
|
||||||
|
per_page: '1',
|
||||||
|
q: `${params.artist} ${params.name}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Genius search request got an error!', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hit = result.data.response?.sections?.[0]?.hits?.[0]?.result;
|
||||||
|
|
||||||
|
if (!hit) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
artist: hit.artist_names,
|
||||||
|
id: hit.url,
|
||||||
|
name: hit.full_title,
|
||||||
|
source: LyricSource.GENIUS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLyricsBySongId(url: string): Promise<string | null> {
|
||||||
|
let result: AxiosResponse<string, any>;
|
||||||
|
try {
|
||||||
|
result = await axios.get<string>(url, { responseType: 'text' });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Genius lyrics request got an error!', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const $ = load(result.data.split('<br/>').join('\n'));
|
||||||
|
const lyricsDiv = $('div.lyrics');
|
||||||
|
|
||||||
|
if (lyricsDiv.length > 0) return lyricsDiv.text().trim();
|
||||||
|
|
||||||
|
const lyricSections = $('div[class^=Lyrics__Container]')
|
||||||
|
.map((_, e) => $(e).text())
|
||||||
|
.toArray()
|
||||||
|
.join('\n');
|
||||||
|
return lyricSections;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function query(
|
||||||
|
params: LyricSearchQuery,
|
||||||
|
): Promise<InternetProviderLyricResponse | null> {
|
||||||
|
const response = await getSongId(params);
|
||||||
|
if (!response) {
|
||||||
|
console.error('Could not find the song on Genius!');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lyrics = await getLyricsBySongId(response.id);
|
||||||
|
if (!lyrics) {
|
||||||
|
console.error('Could not get lyrics on Genius!');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
artist: response.artist,
|
||||||
|
id: response.id,
|
||||||
|
lyrics,
|
||||||
|
name: response.name,
|
||||||
|
source: LyricSource.GENIUS,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { ipcMain } from 'electron';
|
||||||
|
import {
|
||||||
|
query as queryGenius,
|
||||||
|
getSearchResults as searchGenius,
|
||||||
|
getLyricsBySongId as getGenius,
|
||||||
|
} from './genius';
|
||||||
|
import {
|
||||||
|
query as queryLrclib,
|
||||||
|
getSearchResults as searchLrcLib,
|
||||||
|
getLyricsBySongId as getLrcLib,
|
||||||
|
} from './lrclib';
|
||||||
|
import {
|
||||||
|
query as queryNetease,
|
||||||
|
getSearchResults as searchNetease,
|
||||||
|
getLyricsBySongId as getNetease,
|
||||||
|
} from './netease';
|
||||||
|
import {
|
||||||
|
InternetProviderLyricResponse,
|
||||||
|
InternetProviderLyricSearchResponse,
|
||||||
|
LyricSearchQuery,
|
||||||
|
QueueSong,
|
||||||
|
LyricGetQuery,
|
||||||
|
LyricSource,
|
||||||
|
} from '../../../../renderer/api/types';
|
||||||
|
import { store } from '../settings/index';
|
||||||
|
|
||||||
|
type SongFetcher = (params: LyricSearchQuery) => Promise<InternetProviderLyricResponse | null>;
|
||||||
|
type SearchFetcher = (
|
||||||
|
params: LyricSearchQuery,
|
||||||
|
) => Promise<InternetProviderLyricSearchResponse[] | null>;
|
||||||
|
type GetFetcher = (id: string) => Promise<string | null>;
|
||||||
|
|
||||||
|
type CachedLyrics = Record<LyricSource, InternetProviderLyricResponse>;
|
||||||
|
|
||||||
|
const FETCHERS: Record<LyricSource, SongFetcher> = {
|
||||||
|
[LyricSource.GENIUS]: queryGenius,
|
||||||
|
[LyricSource.LRCLIB]: queryLrclib,
|
||||||
|
[LyricSource.NETEASE]: queryNetease,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SEARCH_FETCHERS: Record<LyricSource, SearchFetcher> = {
|
||||||
|
[LyricSource.GENIUS]: searchGenius,
|
||||||
|
[LyricSource.LRCLIB]: searchLrcLib,
|
||||||
|
[LyricSource.NETEASE]: searchNetease,
|
||||||
|
};
|
||||||
|
|
||||||
|
const GET_FETCHERS: Record<LyricSource, GetFetcher> = {
|
||||||
|
[LyricSource.GENIUS]: getGenius,
|
||||||
|
[LyricSource.LRCLIB]: getLrcLib,
|
||||||
|
[LyricSource.NETEASE]: getNetease,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_CACHED_ITEMS = 10;
|
||||||
|
|
||||||
|
const lyricCache = new Map<string, CachedLyrics>();
|
||||||
|
|
||||||
|
const getRemoteLyrics = async (song: QueueSong) => {
|
||||||
|
const sources = store.get('lyrics', []) as LyricSource[];
|
||||||
|
|
||||||
|
const cached = lyricCache.get(song.id);
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
for (const source of sources) {
|
||||||
|
const data = cached[source];
|
||||||
|
if (data) return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lyricsFromSource = null;
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
|
const params = {
|
||||||
|
album: song.album || song.name,
|
||||||
|
artist: song.artistName,
|
||||||
|
duration: song.duration,
|
||||||
|
name: song.name,
|
||||||
|
};
|
||||||
|
const response = await FETCHERS[source](params);
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
const newResult = cached
|
||||||
|
? {
|
||||||
|
...cached,
|
||||||
|
[source]: response,
|
||||||
|
}
|
||||||
|
: ({ [source]: response } as CachedLyrics);
|
||||||
|
|
||||||
|
if (lyricCache.size === MAX_CACHED_ITEMS && cached === undefined) {
|
||||||
|
const toRemove = lyricCache.keys().next().value;
|
||||||
|
lyricCache.delete(toRemove);
|
||||||
|
}
|
||||||
|
|
||||||
|
lyricCache.set(song.id, newResult);
|
||||||
|
|
||||||
|
lyricsFromSource = response;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lyricsFromSource;
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchRemoteLyrics = async (params: LyricSearchQuery) => {
|
||||||
|
const sources = store.get('lyrics', []) as LyricSource[];
|
||||||
|
|
||||||
|
const results: Record<LyricSource, InternetProviderLyricSearchResponse[]> = {
|
||||||
|
[LyricSource.GENIUS]: [],
|
||||||
|
[LyricSource.LRCLIB]: [],
|
||||||
|
[LyricSource.NETEASE]: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
|
const response = await SEARCH_FETCHERS[source](params);
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
response.forEach((result) => {
|
||||||
|
results[source].push(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRemoteLyricsById = async (params: LyricGetQuery): Promise<string | null> => {
|
||||||
|
const { remoteSongId, remoteSource } = params;
|
||||||
|
const response = await GET_FETCHERS[remoteSource](remoteSongId);
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
ipcMain.handle('lyric-by-song', async (_event, song: QueueSong) => {
|
||||||
|
const lyric = await getRemoteLyrics(song);
|
||||||
|
return lyric;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('lyric-search', async (_event, params: LyricSearchQuery) => {
|
||||||
|
const lyricResults = await searchRemoteLyrics(params);
|
||||||
|
return lyricResults;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('lyric-by-remote-id', async (_event, params: LyricGetQuery) => {
|
||||||
|
const lyricResults = await getRemoteLyricsById(params);
|
||||||
|
return lyricResults;
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// Credits to https://github.com/tranxuanthang/lrcget for API implementation
|
||||||
|
import axios, { AxiosResponse } from 'axios';
|
||||||
|
import { orderSearchResults } from './shared';
|
||||||
|
import {
|
||||||
|
InternetProviderLyricResponse,
|
||||||
|
InternetProviderLyricSearchResponse,
|
||||||
|
LyricSearchQuery,
|
||||||
|
LyricSource,
|
||||||
|
} from '../../../../renderer/api/types';
|
||||||
|
|
||||||
|
const FETCH_URL = 'https://lrclib.net/api/get';
|
||||||
|
const SEEARCH_URL = 'https://lrclib.net/api/search';
|
||||||
|
|
||||||
|
const TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
export interface LrcLibSearchResponse {
|
||||||
|
albumName: string;
|
||||||
|
artistName: string;
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LrcLibTrackResponse {
|
||||||
|
albumName: string;
|
||||||
|
artistName: string;
|
||||||
|
duration: number;
|
||||||
|
id: number;
|
||||||
|
instrumental: boolean;
|
||||||
|
isrc: string;
|
||||||
|
lang: string;
|
||||||
|
name: string;
|
||||||
|
plainLyrics: string | null;
|
||||||
|
releaseDate: string;
|
||||||
|
spotifyId: string;
|
||||||
|
syncedLyrics: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSearchResults(
|
||||||
|
params: LyricSearchQuery,
|
||||||
|
): Promise<InternetProviderLyricSearchResponse[] | null> {
|
||||||
|
let result: AxiosResponse<LrcLibSearchResponse[]>;
|
||||||
|
|
||||||
|
if (!params.name) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
result = await axios.get<LrcLibSearchResponse[]>(SEEARCH_URL, {
|
||||||
|
params: {
|
||||||
|
q: params.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('LrcLib search request got an error!', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.data) return null;
|
||||||
|
|
||||||
|
const songResults: InternetProviderLyricSearchResponse[] = result.data.map((song) => {
|
||||||
|
return {
|
||||||
|
artist: song.artistName,
|
||||||
|
id: String(song.id),
|
||||||
|
name: song.name,
|
||||||
|
source: LyricSource.LRCLIB,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return orderSearchResults({ params, results: songResults });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLyricsBySongId(songId: string): Promise<string | null> {
|
||||||
|
let result: AxiosResponse<LrcLibTrackResponse, any>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
result = await axios.get<LrcLibTrackResponse>(`${FETCH_URL}/${songId}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('LrcLib lyrics request got an error!', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data.syncedLyrics || result.data.plainLyrics || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function query(
|
||||||
|
params: LyricSearchQuery,
|
||||||
|
): Promise<InternetProviderLyricResponse | null> {
|
||||||
|
let result: AxiosResponse<LrcLibTrackResponse, any>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
result = await axios.get<LrcLibTrackResponse>(FETCH_URL, {
|
||||||
|
params: {
|
||||||
|
album_name: params.album,
|
||||||
|
artist_name: params.artist,
|
||||||
|
duration: params.duration,
|
||||||
|
track_name: params.name,
|
||||||
|
},
|
||||||
|
timeout: TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('LrcLib search request got an error!', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lyrics = result.data.syncedLyrics || result.data.plainLyrics || null;
|
||||||
|
|
||||||
|
if (!lyrics) {
|
||||||
|
console.error(`Could not get lyrics on LrcLib!`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
artist: result.data.artistName,
|
||||||
|
id: String(result.data.id),
|
||||||
|
lyrics,
|
||||||
|
name: result.data.name,
|
||||||
|
source: LyricSource.LRCLIB,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import axios, { AxiosResponse } from 'axios';
|
||||||
|
import { LyricSource } from '../../../../renderer/api/types';
|
||||||
|
import { orderSearchResults } from './shared';
|
||||||
|
import type {
|
||||||
|
InternetProviderLyricResponse,
|
||||||
|
InternetProviderLyricSearchResponse,
|
||||||
|
LyricSearchQuery,
|
||||||
|
} from '/@/renderer/api/types';
|
||||||
|
|
||||||
|
const SEARCH_URL = 'https://music.163.com/api/search/get';
|
||||||
|
const LYRICS_URL = 'https://music.163.com/api/song/lyric';
|
||||||
|
|
||||||
|
// Adapted from https://github.com/NyaomiDEV/Sunamu/blob/master/src/main/lyricproviders/netease.ts
|
||||||
|
|
||||||
|
export interface NetEaseResponse {
|
||||||
|
code: number;
|
||||||
|
result: Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Result {
|
||||||
|
hasMore: boolean;
|
||||||
|
songCount: number;
|
||||||
|
songs: Song[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Song {
|
||||||
|
album: Album;
|
||||||
|
alias: string[];
|
||||||
|
artists: Artist[];
|
||||||
|
copyrightId: number;
|
||||||
|
duration: number;
|
||||||
|
fee: number;
|
||||||
|
ftype: number;
|
||||||
|
id: number;
|
||||||
|
mark: number;
|
||||||
|
mvid: number;
|
||||||
|
name: string;
|
||||||
|
rUrl: null;
|
||||||
|
rtype: number;
|
||||||
|
status: number;
|
||||||
|
transNames?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Album {
|
||||||
|
artist: Artist;
|
||||||
|
copyrightId: number;
|
||||||
|
id: number;
|
||||||
|
mark: number;
|
||||||
|
name: string;
|
||||||
|
picId: number;
|
||||||
|
publishTime: number;
|
||||||
|
size: number;
|
||||||
|
status: number;
|
||||||
|
transNames?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Artist {
|
||||||
|
albumSize: number;
|
||||||
|
alias: any[];
|
||||||
|
fansGroup: null;
|
||||||
|
id: number;
|
||||||
|
img1v1: number;
|
||||||
|
img1v1Url: string;
|
||||||
|
name: string;
|
||||||
|
picId: number;
|
||||||
|
picUrl: null;
|
||||||
|
trans: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSearchResults(
|
||||||
|
params: LyricSearchQuery,
|
||||||
|
): Promise<InternetProviderLyricSearchResponse[] | null> {
|
||||||
|
let result: AxiosResponse<NetEaseResponse>;
|
||||||
|
|
||||||
|
const searchQuery = [params.artist, params.name].join(' ');
|
||||||
|
|
||||||
|
if (!searchQuery) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
result = await axios.get(SEARCH_URL, {
|
||||||
|
params: {
|
||||||
|
limit: 5,
|
||||||
|
offset: 0,
|
||||||
|
s: searchQuery,
|
||||||
|
type: '1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('NetEase search request got an error!', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawSongsResult = result?.data.result?.songs;
|
||||||
|
|
||||||
|
if (!rawSongsResult) return null;
|
||||||
|
|
||||||
|
const songResults: InternetProviderLyricSearchResponse[] = rawSongsResult.map((song) => {
|
||||||
|
const artist = song.artists ? song.artists.map((artist) => artist.name).join(', ') : '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
artist,
|
||||||
|
id: String(song.id),
|
||||||
|
name: song.name,
|
||||||
|
source: LyricSource.NETEASE,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return orderSearchResults({ params, results: songResults });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMatchedLyrics(
|
||||||
|
params: LyricSearchQuery,
|
||||||
|
): Promise<Omit<InternetProviderLyricResponse, 'lyrics'> | null> {
|
||||||
|
const results = await getSearchResults(params);
|
||||||
|
|
||||||
|
const firstMatch = results?.[0];
|
||||||
|
|
||||||
|
if (!firstMatch || (firstMatch?.score && firstMatch.score > 0.5)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLyricsBySongId(songId: string): Promise<string | null> {
|
||||||
|
let result: AxiosResponse<any, any>;
|
||||||
|
try {
|
||||||
|
result = await axios.get(LYRICS_URL, {
|
||||||
|
params: {
|
||||||
|
id: songId,
|
||||||
|
kv: '-1',
|
||||||
|
lv: '-1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('NetEase lyrics request got an error!', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data.klyric?.lyric || result.data.lrc?.lyric;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function query(
|
||||||
|
params: LyricSearchQuery,
|
||||||
|
): Promise<InternetProviderLyricResponse | null> {
|
||||||
|
const lyricsMatch = await getMatchedLyrics(params);
|
||||||
|
if (!lyricsMatch) {
|
||||||
|
console.error('Could not find the song on NetEase!');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lyrics = await getLyricsBySongId(lyricsMatch.id);
|
||||||
|
if (!lyrics) {
|
||||||
|
console.error('Could not get lyrics on NetEase!');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
artist: lyricsMatch.artist,
|
||||||
|
id: lyricsMatch.id,
|
||||||
|
lyrics,
|
||||||
|
name: lyricsMatch.name,
|
||||||
|
source: LyricSource.NETEASE,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import Fuse from 'fuse.js';
|
||||||
|
import {
|
||||||
|
InternetProviderLyricSearchResponse,
|
||||||
|
LyricSearchQuery,
|
||||||
|
} from '../../../../renderer/api/types';
|
||||||
|
|
||||||
|
export const orderSearchResults = (args: {
|
||||||
|
params: LyricSearchQuery;
|
||||||
|
results: InternetProviderLyricSearchResponse[];
|
||||||
|
}) => {
|
||||||
|
const { params, results } = args;
|
||||||
|
|
||||||
|
const options: Fuse.IFuseOptions<InternetProviderLyricSearchResponse> = {
|
||||||
|
fieldNormWeight: 1,
|
||||||
|
includeScore: true,
|
||||||
|
keys: [
|
||||||
|
{ getFn: (song) => song.name, name: 'name', weight: 3 },
|
||||||
|
{ getFn: (song) => song.artist, name: 'artist' },
|
||||||
|
],
|
||||||
|
threshold: 1.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fuse = new Fuse(results, options);
|
||||||
|
|
||||||
|
const searchResults = fuse.search<InternetProviderLyricSearchResponse>({
|
||||||
|
...(params.artist && { artist: params.artist }),
|
||||||
|
...(params.name && { name: params.name }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return searchResults.map((result) => ({
|
||||||
|
...result.item,
|
||||||
|
score: result.score,
|
||||||
|
}));
|
||||||
|
};
|
||||||
@@ -1,98 +1,165 @@
|
|||||||
|
import console from 'console';
|
||||||
import { ipcMain } from 'electron';
|
import { ipcMain } from 'electron';
|
||||||
import { getMpvInstance } from '../../../main';
|
import { getMpvInstance } from '../../../main';
|
||||||
import { PlayerData } from '/@/renderer/store';
|
import { PlayerData } from '/@/renderer/store';
|
||||||
|
|
||||||
declare module 'node-mpv';
|
declare module 'node-mpv';
|
||||||
|
|
||||||
function wait(timeout: number) {
|
// function wait(timeout: number) {
|
||||||
return new Promise((resolve) => {
|
// return new Promise((resolve) => {
|
||||||
setTimeout(() => {
|
// setTimeout(() => {
|
||||||
resolve('resolved');
|
// resolve('resolved');
|
||||||
}, timeout);
|
// }, timeout);
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
ipcMain.handle('player-is-running', async () => {
|
||||||
|
return getMpvInstance()?.isRunning();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('player-clean-up', async () => {
|
||||||
|
getMpvInstance()?.stop();
|
||||||
|
getMpvInstance()?.clearPlaylist();
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
ipcMain.on('player-start', async () => {
|
ipcMain.on('player-start', async () => {
|
||||||
await getMpvInstance()?.play();
|
await getMpvInstance()
|
||||||
|
?.play()
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('MPV failed to play', err);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Starts the player
|
// Starts the player
|
||||||
ipcMain.on('player-play', async () => {
|
ipcMain.on('player-play', async () => {
|
||||||
await getMpvInstance()?.play();
|
await getMpvInstance()
|
||||||
|
?.play()
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('MPV failed to play', err);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pauses the player
|
// Pauses the player
|
||||||
ipcMain.on('player-pause', async () => {
|
ipcMain.on('player-pause', async () => {
|
||||||
await getMpvInstance()?.pause();
|
await getMpvInstance()
|
||||||
|
?.pause()
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('MPV failed to pause', err);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stops the player
|
// Stops the player
|
||||||
ipcMain.on('player-stop', async () => {
|
ipcMain.on('player-stop', async () => {
|
||||||
await getMpvInstance()?.stop();
|
await getMpvInstance()
|
||||||
|
?.stop()
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('MPV failed to stop', err);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Goes to the next track in the playlist
|
// Goes to the next track in the playlist
|
||||||
ipcMain.on('player-next', async () => {
|
ipcMain.on('player-next', async () => {
|
||||||
await getMpvInstance()?.next();
|
await getMpvInstance()
|
||||||
|
?.next()
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('MPV failed to go to next', err);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Goes to the previous track in the playlist
|
// Goes to the previous track in the playlist
|
||||||
ipcMain.on('player-previous', async () => {
|
ipcMain.on('player-previous', async () => {
|
||||||
await getMpvInstance()?.prev();
|
await getMpvInstance()
|
||||||
|
?.prev()
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('MPV failed to go to previous', err);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Seeks forward or backward by the given amount of seconds
|
// Seeks forward or backward by the given amount of seconds
|
||||||
ipcMain.on('player-seek', async (_event, time: number) => {
|
ipcMain.on('player-seek', async (_event, time: number) => {
|
||||||
await getMpvInstance()?.seek(time);
|
await getMpvInstance()
|
||||||
|
?.seek(time)
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('MPV failed to seek', err);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Seeks to the given time in seconds
|
// Seeks to the given time in seconds
|
||||||
ipcMain.on('player-seek-to', async (_event, time: number) => {
|
ipcMain.on('player-seek-to', async (_event, time: number) => {
|
||||||
await getMpvInstance()?.goToPosition(time);
|
await getMpvInstance()
|
||||||
|
?.goToPosition(time)
|
||||||
|
.catch((err) => {
|
||||||
|
console.log(`MPV failed to seek to ${time}`, err);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sets the queue in position 0 and 1 to the given data. Used when manually starting a song or using the next/prev buttons
|
// Sets the queue in position 0 and 1 to the given data. Used when manually starting a song or using the next/prev buttons
|
||||||
ipcMain.on('player-set-queue', async (_event, data: PlayerData) => {
|
ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean) => {
|
||||||
if (!data.queue.current && !data.queue.next) {
|
if (!data.queue.current && !data.queue.next) {
|
||||||
await getMpvInstance()?.clearPlaylist();
|
await getMpvInstance()
|
||||||
await getMpvInstance()?.pause();
|
?.clearPlaylist()
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('MPV failed to clear playlist', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
await getMpvInstance()
|
||||||
|
?.pause()
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('MPV failed to pause', err);
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let complete = false;
|
|
||||||
|
|
||||||
while (!complete) {
|
|
||||||
try {
|
try {
|
||||||
if (data.queue.current) {
|
if (data.queue.current) {
|
||||||
await getMpvInstance()?.load(data.queue.current.streamUrl, 'replace');
|
getMpvInstance()
|
||||||
}
|
?.load(data.queue.current.streamUrl, 'replace')
|
||||||
|
.then(() => {
|
||||||
|
// eslint-disable-next-line promise/always-return
|
||||||
if (data.queue.next) {
|
if (data.queue.next) {
|
||||||
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
|
getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('MPV failed to load song', err);
|
||||||
|
getMpvInstance()?.play();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
complete = true;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
await wait(500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pause) {
|
||||||
|
getMpvInstance()?.pause();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Replaces the queue in position 1 to the given data
|
// Replaces the queue in position 1 to the given data
|
||||||
ipcMain.on('player-set-queue-next', async (_event, data: PlayerData) => {
|
ipcMain.on('player-set-queue-next', async (_event, data: PlayerData) => {
|
||||||
const size = await getMpvInstance()?.getPlaylistSize();
|
const size = await getMpvInstance()
|
||||||
|
?.getPlaylistSize()
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('MPV failed to get playlist size', err);
|
||||||
|
});
|
||||||
|
|
||||||
if (!size) {
|
if (!size) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (size > 1) {
|
if (size > 1) {
|
||||||
await getMpvInstance()?.playlistRemove(1);
|
await getMpvInstance()
|
||||||
|
?.playlistRemove(1)
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('MPV failed to remove song from playlist', err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.queue.next) {
|
if (data.queue.next) {
|
||||||
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
|
await getMpvInstance()
|
||||||
|
?.load(data.queue.next.streamUrl, 'append')
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('MPV failed to load next song', err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,23 +168,40 @@ ipcMain.on('player-auto-next', async (_event, data: PlayerData) => {
|
|||||||
// Always keep the current song as position 0 in the mpv queue
|
// Always keep the current song as position 0 in the mpv queue
|
||||||
// This allows us to easily set update the next song in the queue without
|
// This allows us to easily set update the next song in the queue without
|
||||||
// disturbing the currently playing song
|
// disturbing the currently playing song
|
||||||
await getMpvInstance()?.playlistRemove(0);
|
await getMpvInstance()
|
||||||
|
?.playlistRemove(0)
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('MPV failed to remove song from playlist', err);
|
||||||
|
getMpvInstance()?.pause();
|
||||||
|
});
|
||||||
|
|
||||||
if (data.queue.next) {
|
if (data.queue.next) {
|
||||||
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
|
await getMpvInstance()
|
||||||
|
?.load(data.queue.next.streamUrl, 'append')
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('MPV failed to load next song', err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sets the volume to the given value (0-100)
|
// Sets the volume to the given value (0-100)
|
||||||
ipcMain.on('player-volume', async (_event, value: number) => {
|
ipcMain.on('player-volume', async (_event, value: number) => {
|
||||||
await getMpvInstance()?.volume(value);
|
await getMpvInstance()
|
||||||
|
?.volume(value)
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('MPV failed to set volume', err);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Toggles the mute status
|
// Toggles the mute status
|
||||||
ipcMain.on('player-mute', async () => {
|
ipcMain.on('player-mute', async (_event, mute: boolean) => {
|
||||||
await getMpvInstance()?.mute();
|
await getMpvInstance()
|
||||||
|
?.mute(mute)
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('MPV failed to toggle mute', err);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('player-quit', async () => {
|
ipcMain.handle('player-get-time', async (): Promise<number | undefined> => {
|
||||||
await getMpvInstance()?.stop();
|
return getMpvInstance()?.getTimePosition();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,651 @@
|
|||||||
|
import { Stats, promises } from 'fs';
|
||||||
|
import { readFile } from 'fs/promises';
|
||||||
|
import { IncomingMessage, Server, ServerResponse, createServer } from 'http';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { deflate, gzip } from 'zlib';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { app, ipcMain } from 'electron';
|
||||||
|
import { Server as WsServer, WebSocketServer, WebSocket } from 'ws';
|
||||||
|
import { ClientEvent, ServerEvent } from '../../../../remote/types';
|
||||||
|
import { PlayerRepeat, SongUpdate } from '../../../../renderer/types';
|
||||||
|
import { getMainWindow } from '../../../main';
|
||||||
|
import { isLinux } from '../../../utils';
|
||||||
|
|
||||||
|
let mprisPlayer: any | undefined;
|
||||||
|
|
||||||
|
if (isLinux()) {
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
mprisPlayer = require('../../linux/mpris').mprisPlayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RemoteConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
password: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MimeType {
|
||||||
|
css: string;
|
||||||
|
html: string;
|
||||||
|
ico: string;
|
||||||
|
js: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatefulWebSocket extends WebSocket {
|
||||||
|
alive: boolean;
|
||||||
|
auth: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let server: Server | undefined;
|
||||||
|
let wsServer: WsServer<StatefulWebSocket> | undefined;
|
||||||
|
|
||||||
|
const settings: RemoteConfig = {
|
||||||
|
enabled: false,
|
||||||
|
password: '',
|
||||||
|
port: 4333,
|
||||||
|
username: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
type SendData = ServerEvent & {
|
||||||
|
client: StatefulWebSocket;
|
||||||
|
};
|
||||||
|
|
||||||
|
function send({ client, event, data }: SendData): void {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
if (client.alive && client.auth) {
|
||||||
|
client.send(JSON.stringify({ data, event }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcast(message: ServerEvent): void {
|
||||||
|
if (wsServer) {
|
||||||
|
for (const client of wsServer.clients) {
|
||||||
|
send({ client, ...message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shutdownServer = () => {
|
||||||
|
if (wsServer) {
|
||||||
|
wsServer.clients.forEach((client) => client.close(4000));
|
||||||
|
wsServer.close();
|
||||||
|
wsServer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server) {
|
||||||
|
server.close();
|
||||||
|
server = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const MIME_TYPES: MimeType = {
|
||||||
|
css: 'text/css',
|
||||||
|
html: 'text/html; charset=UTF-8',
|
||||||
|
ico: 'image/x-icon',
|
||||||
|
js: 'application/javascript',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PING_TIMEOUT_MS = 10000;
|
||||||
|
const UP_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
enum Encoding {
|
||||||
|
GZIP = 'gzip',
|
||||||
|
NONE = 'none',
|
||||||
|
ZLIB = 'deflate',
|
||||||
|
}
|
||||||
|
|
||||||
|
const GZIP_REGEX = /\bgzip\b/;
|
||||||
|
const ZLIB_REGEX = /bdeflate\b/;
|
||||||
|
|
||||||
|
let currentSong: SongUpdate = {
|
||||||
|
currentTime: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEncoding = (encoding: string | string[]): Encoding => {
|
||||||
|
const encodingArray = Array.isArray(encoding) ? encoding : [encoding];
|
||||||
|
|
||||||
|
for (const code of encodingArray) {
|
||||||
|
if (code.match(GZIP_REGEX)) {
|
||||||
|
return Encoding.GZIP;
|
||||||
|
}
|
||||||
|
if (code.match(ZLIB_REGEX)) {
|
||||||
|
return Encoding.ZLIB;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Encoding.NONE;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cache = new Map<string, Map<Encoding, [number, Buffer]>>();
|
||||||
|
|
||||||
|
function setOk(
|
||||||
|
res: ServerResponse,
|
||||||
|
mtimeMs: number,
|
||||||
|
extension: keyof MimeType,
|
||||||
|
encoding: Encoding,
|
||||||
|
data?: Buffer,
|
||||||
|
) {
|
||||||
|
res.statusCode = data ? 200 : 304;
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', MIME_TYPES[extension]);
|
||||||
|
res.setHeader('ETag', `"${mtimeMs}"`);
|
||||||
|
res.setHeader('Cache-Control', 'public');
|
||||||
|
|
||||||
|
if (encoding !== 'none') res.setHeader('Content-Encoding', encoding);
|
||||||
|
res.end(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serveFile(
|
||||||
|
req: IncomingMessage,
|
||||||
|
file: string,
|
||||||
|
extension: keyof MimeType,
|
||||||
|
res: ServerResponse,
|
||||||
|
): Promise<void> {
|
||||||
|
const fileName = `${file}.${extension}`;
|
||||||
|
const path = app.isPackaged
|
||||||
|
? join(__dirname, '../remote', fileName)
|
||||||
|
: join(__dirname, '../../../../../.erb/dll', fileName);
|
||||||
|
|
||||||
|
let stats: Stats;
|
||||||
|
|
||||||
|
try {
|
||||||
|
stats = await promises.stat(path);
|
||||||
|
} catch (error) {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.setHeader('Content-Type', 'text/plain');
|
||||||
|
res.end((error as Error).message);
|
||||||
|
// This is a resolve, even though it is an error, because we want specific (non 500) status
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodings = req.headers['accept-encoding'] ?? '';
|
||||||
|
const selectedEncoding = getEncoding(encodings);
|
||||||
|
|
||||||
|
const ifMatch = req.headers['if-none-match'];
|
||||||
|
|
||||||
|
const fileInfo = cache.get(fileName);
|
||||||
|
let cached = fileInfo?.get(selectedEncoding);
|
||||||
|
|
||||||
|
if (cached && cached[0] !== stats.mtimeMs) {
|
||||||
|
cache.get(fileName)!.delete(selectedEncoding);
|
||||||
|
cached = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ifMatch && cached) {
|
||||||
|
const options = ifMatch.split(',');
|
||||||
|
|
||||||
|
for (const option of options) {
|
||||||
|
const mTime = Number(option.replaceAll('"', '').trim());
|
||||||
|
|
||||||
|
if (cached[0] === mTime) {
|
||||||
|
setOk(res, cached[0], extension, selectedEncoding);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cached || cached[0] !== stats.mtimeMs) {
|
||||||
|
const content = await readFile(path);
|
||||||
|
|
||||||
|
switch (selectedEncoding) {
|
||||||
|
case Encoding.GZIP:
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
gzip(content, (error, result) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newEntry: [number, Buffer] = [stats.mtimeMs, result];
|
||||||
|
|
||||||
|
if (fileInfo) {
|
||||||
|
fileInfo.set(selectedEncoding, newEntry);
|
||||||
|
} else {
|
||||||
|
cache.set(fileName, new Map([[selectedEncoding, newEntry]]));
|
||||||
|
}
|
||||||
|
|
||||||
|
setOk(res, stats.mtimeMs, extension, selectedEncoding, result);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
case Encoding.ZLIB:
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
deflate(content, (error, result) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newEntry: [number, Buffer] = [stats.mtimeMs, result];
|
||||||
|
|
||||||
|
if (fileInfo) {
|
||||||
|
fileInfo.set(selectedEncoding, newEntry);
|
||||||
|
} else {
|
||||||
|
cache.set(fileName, new Map([[selectedEncoding, newEntry]]));
|
||||||
|
}
|
||||||
|
|
||||||
|
setOk(res, stats.mtimeMs, extension, selectedEncoding, result);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
default: {
|
||||||
|
const newEntry: [number, Buffer] = [stats.mtimeMs, content];
|
||||||
|
|
||||||
|
if (fileInfo) {
|
||||||
|
fileInfo.set(selectedEncoding, newEntry);
|
||||||
|
} else {
|
||||||
|
cache.set(fileName, new Map([[selectedEncoding, newEntry]]));
|
||||||
|
}
|
||||||
|
|
||||||
|
setOk(res, stats.mtimeMs, extension, selectedEncoding, content);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setOk(res, cached[0], extension, selectedEncoding, cached[1]);
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorize(req: IncomingMessage): boolean {
|
||||||
|
if (settings.username || settings.password) {
|
||||||
|
// https://stackoverflow.com/questions/23616371/basic-http-authentication-with-node-and-express-4
|
||||||
|
|
||||||
|
const authorization = req.headers.authorization?.split(' ')[1] || '';
|
||||||
|
const [login, password] = Buffer.from(authorization, 'base64').toString().split(':');
|
||||||
|
|
||||||
|
return login === settings.username && password === settings.password;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enableServer = (config: RemoteConfig): Promise<void> => {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
if (server) {
|
||||||
|
server.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
server = createServer({}, async (req, res) => {
|
||||||
|
if (!authorize(req)) {
|
||||||
|
res.statusCode = 401;
|
||||||
|
res.setHeader('WWW-Authenticate', 'Basic realm="401"');
|
||||||
|
res.end('Authorization required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (req.url) {
|
||||||
|
case '/': {
|
||||||
|
await serveFile(req, 'index', 'html', res);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case '/favicon.ico': {
|
||||||
|
await serveFile(req, 'favicon', 'ico', res);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case '/remote.css': {
|
||||||
|
await serveFile(req, 'remote', 'css', res);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case '/remote.js': {
|
||||||
|
await serveFile(req, 'remote', 'js', res);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case '/credentials': {
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader('Content-Type', 'text/plain');
|
||||||
|
res.end(req.headers.authorization);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.setHeader('Content-Type', 'text/plain');
|
||||||
|
res.end('Not FOund');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.setHeader('Content-Type', 'text/plain');
|
||||||
|
res.end((error as Error).message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(config.port, resolve);
|
||||||
|
wsServer = new WebSocketServer({ server });
|
||||||
|
|
||||||
|
wsServer.on('connection', (ws) => {
|
||||||
|
let authFail: number | undefined;
|
||||||
|
ws.alive = true;
|
||||||
|
|
||||||
|
if (!settings.username && !settings.password) {
|
||||||
|
ws.auth = true;
|
||||||
|
} else {
|
||||||
|
authFail = setTimeout(() => {
|
||||||
|
if (!ws.auth) {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
}, 10000) as unknown as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.on('error', console.error);
|
||||||
|
|
||||||
|
ws.on('message', (data) => {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(data.toString()) as ClientEvent;
|
||||||
|
const event = json.event;
|
||||||
|
|
||||||
|
if (!ws.auth) {
|
||||||
|
if (event === 'authenticate') {
|
||||||
|
const auth = json.header.split(' ')[1];
|
||||||
|
const [login, password] = Buffer.from(auth, 'base64')
|
||||||
|
.toString()
|
||||||
|
.split(':');
|
||||||
|
|
||||||
|
if (login === settings.username && password === settings.password) {
|
||||||
|
ws.auth = true;
|
||||||
|
} else {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(authFail);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (event) {
|
||||||
|
case 'pause': {
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-pause');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'play': {
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-play');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'next': {
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-next');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'previous': {
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-previous');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'proxy': {
|
||||||
|
const toFetch = currentSong.song?.imageUrl?.replaceAll(
|
||||||
|
/&(size|width|height=\d+)/g,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!toFetch) return;
|
||||||
|
|
||||||
|
axios
|
||||||
|
.get(toFetch, { responseType: 'arraybuffer' })
|
||||||
|
.then((resp) => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
send({
|
||||||
|
client: ws,
|
||||||
|
data: Buffer.from(resp.data, 'binary').toString(
|
||||||
|
'base64',
|
||||||
|
),
|
||||||
|
event: 'proxy',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
send({
|
||||||
|
client: ws,
|
||||||
|
data: error.message,
|
||||||
|
event: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'repeat': {
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-toggle-repeat');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'shuffle': {
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-toggle-shuffle');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'volume': {
|
||||||
|
let volume = Number(json.volume);
|
||||||
|
|
||||||
|
if (volume > 100) {
|
||||||
|
volume = 100;
|
||||||
|
} else if (volume < 0) {
|
||||||
|
volume = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSong.volume = volume;
|
||||||
|
|
||||||
|
broadcast({ data: { volume }, event: 'song' });
|
||||||
|
getMainWindow()?.webContents.send('request-volume', {
|
||||||
|
volume,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mprisPlayer) {
|
||||||
|
mprisPlayer.volume = volume / 100;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'favorite': {
|
||||||
|
const { favorite, id } = json;
|
||||||
|
if (id && id === currentSong.song?.id) {
|
||||||
|
getMainWindow()?.webContents.send('request-favorite', {
|
||||||
|
favorite,
|
||||||
|
id,
|
||||||
|
serverId: currentSong.song.serverId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'rating': {
|
||||||
|
const { rating, id } = json;
|
||||||
|
if (id && id === currentSong.song?.id) {
|
||||||
|
getMainWindow()?.webContents.send('request-rating', {
|
||||||
|
id,
|
||||||
|
rating,
|
||||||
|
serverId: currentSong.song.serverId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('pong', () => {
|
||||||
|
ws.alive = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({ data: currentSong, event: 'song' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const heartBeat = setInterval(() => {
|
||||||
|
wsServer?.clients.forEach((ws) => {
|
||||||
|
if (!ws.alive) {
|
||||||
|
ws.terminate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.alive = false;
|
||||||
|
ws.ping();
|
||||||
|
});
|
||||||
|
}, PING_TIMEOUT_MS);
|
||||||
|
|
||||||
|
wsServer.on('close', () => {
|
||||||
|
clearInterval(heartBeat);
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
reject(new Error('Server did not come up'));
|
||||||
|
}, UP_TIMEOUT_MS);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
shutdownServer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
ipcMain.handle('remote-enable', async (_event, enabled: boolean) => {
|
||||||
|
settings.enabled = enabled;
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
try {
|
||||||
|
await enableServer(settings);
|
||||||
|
} catch (error) {
|
||||||
|
return (error as Error).message;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
shutdownServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('remote-port', async (_event, port: number) => {
|
||||||
|
settings.port = port;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on('remote-password', (_event, password: string) => {
|
||||||
|
settings.password = password;
|
||||||
|
wsServer?.clients.forEach((client) => client.close(4002));
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
'remote-settings',
|
||||||
|
async (_event, enabled: boolean, port: number, username: string, password: string) => {
|
||||||
|
settings.enabled = enabled;
|
||||||
|
settings.password = password;
|
||||||
|
settings.port = port;
|
||||||
|
settings.username = username;
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
try {
|
||||||
|
await enableServer(settings);
|
||||||
|
} catch (error) {
|
||||||
|
return (error as Error).message;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
shutdownServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ipcMain.on('remote-username', (_event, username: string) => {
|
||||||
|
settings.username = username;
|
||||||
|
wsServer?.clients.forEach((client) => client.close(4002));
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on('update-favorite', (_event, favorite: boolean, serverId: string, ids: string[]) => {
|
||||||
|
if (currentSong.song?.serverId !== serverId) return;
|
||||||
|
|
||||||
|
const id = currentSong.song.id;
|
||||||
|
|
||||||
|
for (const songId of ids) {
|
||||||
|
if (songId === id) {
|
||||||
|
currentSong.song.userFavorite = favorite;
|
||||||
|
broadcast({ data: { favorite, id: songId }, event: 'favorite' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on('update-rating', (_event, rating: number, serverId: string, ids: string[]) => {
|
||||||
|
if (currentSong.song?.serverId !== serverId) return;
|
||||||
|
|
||||||
|
const id = currentSong.song.id;
|
||||||
|
|
||||||
|
for (const songId of ids) {
|
||||||
|
if (songId === id) {
|
||||||
|
currentSong.song.userRating = rating;
|
||||||
|
broadcast({ data: { id: songId, rating }, event: 'rating' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on('update-repeat', (_event, repeat: PlayerRepeat) => {
|
||||||
|
currentSong.repeat = repeat;
|
||||||
|
broadcast({ data: { repeat }, event: 'song' });
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on('update-shuffle', (_event, shuffle: boolean) => {
|
||||||
|
currentSong.shuffle = shuffle;
|
||||||
|
broadcast({ data: { shuffle }, event: 'song' });
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on('update-song', (_event, data: SongUpdate) => {
|
||||||
|
const { song, ...rest } = data;
|
||||||
|
const songChanged = song?.id !== currentSong.song?.id;
|
||||||
|
|
||||||
|
if (!song?.id) {
|
||||||
|
currentSong = {
|
||||||
|
...currentSong,
|
||||||
|
...data,
|
||||||
|
song: undefined,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
currentSong = {
|
||||||
|
...currentSong,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (songChanged) {
|
||||||
|
broadcast({ data: { ...rest, song: song || null }, event: 'song' });
|
||||||
|
} else {
|
||||||
|
broadcast({ data: rest, event: 'song' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on('update-volume', (_event, volume: number) => {
|
||||||
|
currentSong.volume = volume;
|
||||||
|
broadcast({ data: { volume }, event: 'song' });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mprisPlayer) {
|
||||||
|
mprisPlayer.on('loopStatus', (event: string) => {
|
||||||
|
const repeat =
|
||||||
|
event === 'Playlist'
|
||||||
|
? PlayerRepeat.ALL
|
||||||
|
: event === 'Track'
|
||||||
|
? PlayerRepeat.ONE
|
||||||
|
: PlayerRepeat.NONE;
|
||||||
|
|
||||||
|
currentSong.repeat = repeat;
|
||||||
|
broadcast({ data: { repeat }, event: 'song' });
|
||||||
|
});
|
||||||
|
|
||||||
|
mprisPlayer.on('shuffle', (shuffle: boolean) => {
|
||||||
|
currentSong.shuffle = shuffle;
|
||||||
|
broadcast({ data: { shuffle }, event: 'song' });
|
||||||
|
});
|
||||||
|
|
||||||
|
mprisPlayer.on('volume', (vol: number) => {
|
||||||
|
let volume = Math.round(vol * 100);
|
||||||
|
|
||||||
|
if (volume > 100) {
|
||||||
|
volume = 100;
|
||||||
|
} else if (volume < 0) {
|
||||||
|
volume = 0;
|
||||||
|
}
|
||||||
|
currentSong.volume = volume;
|
||||||
|
broadcast({ data: { volume }, event: 'song' });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ipcMain } from 'electron';
|
import { ipcMain, safeStorage } from 'electron';
|
||||||
import Store from 'electron-store';
|
import Store from 'electron-store';
|
||||||
|
|
||||||
export const store = new Store();
|
export const store = new Store();
|
||||||
@@ -10,3 +10,41 @@ ipcMain.handle('settings-get', (_event, data: { property: string }) => {
|
|||||||
ipcMain.on('settings-set', (__event, data: { property: string; value: any }) => {
|
ipcMain.on('settings-set', (__event, data: { property: string; value: any }) => {
|
||||||
store.set(`${data.property}`, data.value);
|
store.set(`${data.property}`, data.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('password-get', (_event, server: string): string | null => {
|
||||||
|
if (safeStorage.isEncryptionAvailable()) {
|
||||||
|
const servers = store.get('server') as Record<string, string> | undefined;
|
||||||
|
|
||||||
|
if (!servers) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encrypted = servers[server];
|
||||||
|
if (!encrypted) return null;
|
||||||
|
|
||||||
|
const decrypted = safeStorage.decryptString(Buffer.from(encrypted, 'hex'));
|
||||||
|
return decrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on('password-remove', (_event, server: string) => {
|
||||||
|
const passwords = store.get('server', {}) as Record<string, string>;
|
||||||
|
if (server in passwords) {
|
||||||
|
delete passwords[server];
|
||||||
|
}
|
||||||
|
store.set({ server: passwords });
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('password-set', (_event, password: string, server: string) => {
|
||||||
|
if (safeStorage.isEncryptionAvailable()) {
|
||||||
|
const encrypted = safeStorage.encryptString(password);
|
||||||
|
const passwords = store.get('server', {}) as Record<string, string>;
|
||||||
|
passwords[server] = encrypted.toString('hex');
|
||||||
|
store.set({ server: passwords });
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { ipcMain } from 'electron';
|
import { ipcMain } from 'electron';
|
||||||
import Player from 'mpris-service';
|
import Player from 'mpris-service';
|
||||||
import { QueueSong, RelatedArtist } from '../../../renderer/api/types';
|
import { PlayerRepeat, PlayerStatus, SongUpdate } from '../../../renderer/types';
|
||||||
import { getMainWindow } from '../../main';
|
import { getMainWindow } from '../../main';
|
||||||
import { PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/renderer/types';
|
|
||||||
|
|
||||||
const mprisPlayer = Player({
|
const mprisPlayer = Player({
|
||||||
identity: 'Feishin',
|
identity: 'Feishin',
|
||||||
@@ -59,9 +58,17 @@ mprisPlayer.on('previous', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
mprisPlayer.on('volume', (event: any) => {
|
mprisPlayer.on('volume', (vol: number) => {
|
||||||
getMainWindow()?.webContents.send('mpris-request-volume', {
|
let volume = Math.round(vol * 100);
|
||||||
volume: event,
|
|
||||||
|
if (volume > 100) {
|
||||||
|
volume = 100;
|
||||||
|
} else if (volume < 0) {
|
||||||
|
volume = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMainWindow()?.webContents.send('request-volume', {
|
||||||
|
volume,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,13 +83,13 @@ mprisPlayer.on('loopStatus', (event: string) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
mprisPlayer.on('position', (event: any) => {
|
mprisPlayer.on('position', (event: any) => {
|
||||||
getMainWindow()?.webContents.send('mpris-request-position', {
|
getMainWindow()?.webContents.send('request-position', {
|
||||||
position: event.position / 1e6,
|
position: event.position / 1e6,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
mprisPlayer.on('seek', (event: number) => {
|
mprisPlayer.on('seek', (event: number) => {
|
||||||
getMainWindow()?.webContents.send('mpris-request-seek', {
|
getMainWindow()?.webContents.send('request-seek', {
|
||||||
offset: event / 1e6,
|
offset: event / 1e6,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -95,38 +102,32 @@ ipcMain.on('mpris-update-seek', (_event, arg) => {
|
|||||||
mprisPlayer.seeked(arg * 1e6);
|
mprisPlayer.seeked(arg * 1e6);
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('mpris-update-volume', (_event, arg) => {
|
ipcMain.on('update-volume', (_event, volume) => {
|
||||||
mprisPlayer.volume = Number(arg);
|
mprisPlayer.volume = Number(volume) / 100;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('mpris-update-repeat', (_event, arg) => {
|
const REPEAT_TO_MPRIS: Record<PlayerRepeat, string> = {
|
||||||
mprisPlayer.loopStatus = arg;
|
[PlayerRepeat.ALL]: 'Playlist',
|
||||||
|
[PlayerRepeat.ONE]: 'Track',
|
||||||
|
[PlayerRepeat.NONE]: 'None',
|
||||||
|
};
|
||||||
|
|
||||||
|
ipcMain.on('update-repeat', (_event, arg: PlayerRepeat) => {
|
||||||
|
mprisPlayer.loopStatus = REPEAT_TO_MPRIS[arg];
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('mpris-update-shuffle', (_event, arg) => {
|
ipcMain.on('update-shuffle', (_event, shuffle: boolean) => {
|
||||||
mprisPlayer.shuffle = arg;
|
mprisPlayer.shuffle = shuffle;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on(
|
ipcMain.on('update-song', (_event, args: SongUpdate) => {
|
||||||
'mpris-update-song',
|
|
||||||
(
|
|
||||||
_event,
|
|
||||||
args: {
|
|
||||||
currentTime: number;
|
|
||||||
repeat: PlayerRepeat;
|
|
||||||
shuffle: PlayerShuffle;
|
|
||||||
song: QueueSong;
|
|
||||||
status: PlayerStatus;
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
const { song, status, repeat, shuffle } = args || {};
|
const { song, status, repeat, shuffle } = args || {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
mprisPlayer.playbackStatus = status;
|
mprisPlayer.playbackStatus = status === PlayerStatus.PLAYING ? 'Playing' : 'Paused';
|
||||||
|
|
||||||
if (repeat) {
|
if (repeat) {
|
||||||
mprisPlayer.loopStatus =
|
mprisPlayer.loopStatus = REPEAT_TO_MPRIS[repeat];
|
||||||
repeat === 'all' ? 'Playlist' : repeat === 'one' ? 'Track' : 'None';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shuffle) {
|
if (shuffle) {
|
||||||
@@ -144,16 +145,15 @@ ipcMain.on(
|
|||||||
|
|
||||||
mprisPlayer.metadata = {
|
mprisPlayer.metadata = {
|
||||||
'mpris:artUrl': upsizedImageUrl,
|
'mpris:artUrl': upsizedImageUrl,
|
||||||
'mpris:length': song.duration ? Math.round((song.duration || 0) * 1e6) : null,
|
'mpris:length': song.duration ? Math.round((song.duration || 0) * 1e3) : null,
|
||||||
'mpris:trackid': song?.id
|
'mpris:trackid': song.id
|
||||||
? mprisPlayer.objectPath(`track/${song.id?.replace('-', '')}`)
|
? mprisPlayer.objectPath(`track/${song.id?.replace('-', '')}`)
|
||||||
: '',
|
: '',
|
||||||
'xesam:album': song.album || null,
|
'xesam:album': song.album || null,
|
||||||
'xesam:albumArtist': song.albumArtists?.length ? song.albumArtists[0].name : null,
|
'xesam:albumArtist': song.albumArtists?.length
|
||||||
'xesam:artist':
|
? song.albumArtists.map((artist) => artist.name)
|
||||||
song.artists?.length !== 0
|
|
||||||
? song.artists?.map((artist: RelatedArtist) => artist.name)
|
|
||||||
: null,
|
: null,
|
||||||
|
'xesam:artist': song.artists?.length ? song.artists.map((artist) => artist.name) : null,
|
||||||
'xesam:discNumber': song.discNumber ? song.discNumber : null,
|
'xesam:discNumber': song.discNumber ? song.discNumber : null,
|
||||||
'xesam:genre': song.genres?.length ? song.genres.map((genre: any) => genre.name) : null,
|
'xesam:genre': song.genres?.length ? song.genres.map((genre: any) => genre.name) : null,
|
||||||
'xesam:title': song.name || null,
|
'xesam:title': song.name || null,
|
||||||
@@ -164,5 +164,6 @@ ipcMain.on(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
export { mprisPlayer };
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
* When running `npm run build` or `npm run build:main`, this file is compiled to
|
* When running `npm run build` or `npm run build:main`, this file is compiled to
|
||||||
* `./src/main.js` using webpack. This gives us some performance wins.
|
* `./src/main.js` using webpack. This gives us some performance wins.
|
||||||
*/
|
*/
|
||||||
import path from 'path';
|
import { access, constants, readFile, writeFile } from 'fs';
|
||||||
|
import path, { join } from 'path';
|
||||||
|
import { deflate, inflate } from 'zlib';
|
||||||
import {
|
import {
|
||||||
app,
|
app,
|
||||||
BrowserWindow,
|
BrowserWindow,
|
||||||
@@ -18,6 +20,7 @@ import {
|
|||||||
Tray,
|
Tray,
|
||||||
Menu,
|
Menu,
|
||||||
nativeImage,
|
nativeImage,
|
||||||
|
BrowserWindowConstructorOptions,
|
||||||
} from 'electron';
|
} from 'electron';
|
||||||
import electronLocalShortcut from 'electron-localshortcut';
|
import electronLocalShortcut from 'electron-localshortcut';
|
||||||
import log from 'electron-log';
|
import log from 'electron-log';
|
||||||
@@ -27,7 +30,7 @@ import MpvAPI from 'node-mpv';
|
|||||||
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
|
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
|
||||||
import { store } from './features/core/settings/index';
|
import { store } from './features/core/settings/index';
|
||||||
import MenuBuilder from './menu';
|
import MenuBuilder from './menu';
|
||||||
import { isLinux, isMacOS, isWindows, resolveHtmlPath } from './utils';
|
import { hotkeyToElectronAccelerator, isLinux, isMacOS, isWindows, resolveHtmlPath } from './utils';
|
||||||
import './features';
|
import './features';
|
||||||
|
|
||||||
declare module 'node-mpv';
|
declare module 'node-mpv';
|
||||||
@@ -81,6 +84,10 @@ const singleInstance = app.requestSingleInstanceLock();
|
|||||||
|
|
||||||
if (!singleInstance) {
|
if (!singleInstance) {
|
||||||
app.quit();
|
app.quit();
|
||||||
|
} else {
|
||||||
|
app.on('second-instance', () => {
|
||||||
|
mainWindow?.show();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const RESOURCES_PATH = app.isPackaged
|
const RESOURCES_PATH = app.isPackaged
|
||||||
@@ -97,7 +104,6 @@ export const getMainWindow = () => {
|
|||||||
|
|
||||||
const createWinThumbarButtons = () => {
|
const createWinThumbarButtons = () => {
|
||||||
if (isWindows()) {
|
if (isWindows()) {
|
||||||
console.log('setting buttons');
|
|
||||||
getMainWindow()?.setThumbarButtons([
|
getMainWindow()?.setThumbarButtons([
|
||||||
{
|
{
|
||||||
click: () => getMainWindow()?.webContents.send('renderer-player-previous'),
|
click: () => getMainWindow()?.webContents.send('renderer-player-previous'),
|
||||||
@@ -123,7 +129,9 @@ const createTray = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tray = isLinux() ? new Tray(getAssetPath('icon.png')) : new Tray(getAssetPath('icon.ico'));
|
tray = isLinux()
|
||||||
|
? new Tray(getAssetPath('icons/icon.png'))
|
||||||
|
: new Tray(getAssetPath('icons/icon.ico'));
|
||||||
const contextMenu = Menu.buildFromTemplate([
|
const contextMenu = Menu.buildFromTemplate([
|
||||||
{
|
{
|
||||||
click: () => {
|
click: () => {
|
||||||
@@ -182,14 +190,36 @@ const createWindow = async () => {
|
|||||||
await installExtensions();
|
await installExtensions();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nativeFrame = store.get('window_window_bar_style') === 'linux';
|
||||||
|
store.set('window_has_frame', nativeFrame);
|
||||||
|
|
||||||
|
const nativeFrameConfig: Record<string, BrowserWindowConstructorOptions> = {
|
||||||
|
linux: {
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
frame: true,
|
||||||
|
},
|
||||||
|
macOS: {
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
frame: false,
|
||||||
|
titleBarStyle: 'hidden',
|
||||||
|
trafficLightPosition: { x: 10, y: 10 },
|
||||||
|
},
|
||||||
|
windows: {
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
frame: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
|
autoHideMenuBar: true,
|
||||||
frame: false,
|
frame: false,
|
||||||
height: 900,
|
height: 900,
|
||||||
icon: getAssetPath('icon.png'),
|
icon: getAssetPath('icons/icon.png'),
|
||||||
minHeight: 600,
|
minHeight: 640,
|
||||||
minWidth: 640,
|
minWidth: 480,
|
||||||
show: false,
|
show: false,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
|
allowRunningInsecureContent: !!store.get('ignore_ssl'),
|
||||||
backgroundThrottling: false,
|
backgroundThrottling: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
devTools: true,
|
devTools: true,
|
||||||
@@ -197,9 +227,12 @@ const createWindow = async () => {
|
|||||||
preload: app.isPackaged
|
preload: app.isPackaged
|
||||||
? path.join(__dirname, 'preload.js')
|
? path.join(__dirname, 'preload.js')
|
||||||
: path.join(__dirname, '../../.erb/dll/preload.js'),
|
: path.join(__dirname, '../../.erb/dll/preload.js'),
|
||||||
webSecurity: store.get('ignore_cors') ? false : undefined,
|
webSecurity: !store.get('ignore_cors'),
|
||||||
},
|
},
|
||||||
width: 1440,
|
width: 1440,
|
||||||
|
...(nativeFrame && isLinux() && nativeFrameConfig.linux),
|
||||||
|
...(nativeFrame && isMacOS() && nativeFrameConfig.macOS),
|
||||||
|
...(nativeFrame && isWindows() && nativeFrameConfig.windows),
|
||||||
});
|
});
|
||||||
|
|
||||||
electronLocalShortcut.register(mainWindow, 'Ctrl+Shift+I', () => {
|
electronLocalShortcut.register(mainWindow, 'Ctrl+Shift+I', () => {
|
||||||
@@ -227,8 +260,18 @@ const createWindow = async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('app-restart', () => {
|
ipcMain.on('app-restart', () => {
|
||||||
|
// Fix for .AppImage
|
||||||
|
if (process.env.APPIMAGE) {
|
||||||
|
app.exit();
|
||||||
|
app.relaunch({
|
||||||
|
args: process.argv.slice(1).concat(['--appimage-extract-and-run']),
|
||||||
|
execPath: process.env.APPIMAGE,
|
||||||
|
});
|
||||||
|
app.exit(0);
|
||||||
|
} else {
|
||||||
app.relaunch();
|
app.relaunch();
|
||||||
app.exit(0);
|
app.exit(0);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('global-media-keys-enable', () => {
|
ipcMain.on('global-media-keys-enable', () => {
|
||||||
@@ -239,9 +282,39 @@ const createWindow = async () => {
|
|||||||
disableMediaKeys();
|
disableMediaKeys();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.on('player-restore-queue', () => {
|
||||||
|
if (store.get('resume')) {
|
||||||
|
const queueLocation = join(app.getPath('userData'), 'queue');
|
||||||
|
|
||||||
|
access(queueLocation, constants.F_OK, (accessError) => {
|
||||||
|
if (accessError) {
|
||||||
|
console.error('unable to access saved queue: ', accessError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
readFile(queueLocation, (readError, buffer) => {
|
||||||
|
if (readError) {
|
||||||
|
console.error('failed to read saved queue: ', readError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
inflate(buffer, (decompressError, data) => {
|
||||||
|
if (decompressError) {
|
||||||
|
console.error('failed to decompress queue: ', decompressError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = JSON.parse(data.toString());
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-restore-queue', queue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const globalMediaKeysEnabled = store.get('global_media_hotkeys') as boolean;
|
const globalMediaKeysEnabled = store.get('global_media_hotkeys') as boolean;
|
||||||
|
|
||||||
if (globalMediaKeysEnabled) {
|
if (globalMediaKeysEnabled !== false) {
|
||||||
enableMediaKeys(mainWindow);
|
enableMediaKeys(mainWindow);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +336,8 @@ const createWindow = async () => {
|
|||||||
mainWindow = null;
|
mainWindow = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let saved = false;
|
||||||
|
|
||||||
mainWindow.on('close', (event) => {
|
mainWindow.on('close', (event) => {
|
||||||
if (!exitFromTray && store.get('window_exit_to_tray')) {
|
if (!exitFromTray && store.get('window_exit_to_tray')) {
|
||||||
if (isMacOS() && !forceQuit) {
|
if (isMacOS() && !forceQuit) {
|
||||||
@@ -271,6 +346,43 @@ const createWindow = async () => {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
mainWindow?.hide();
|
mainWindow?.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!saved && store.get('resume')) {
|
||||||
|
event.preventDefault();
|
||||||
|
saved = true;
|
||||||
|
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-save-queue');
|
||||||
|
|
||||||
|
ipcMain.once('player-save-queue', async (_event, data: Record<string, any>) => {
|
||||||
|
const queueLocation = join(app.getPath('userData'), 'queue');
|
||||||
|
const serialized = JSON.stringify(data);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
deflate(serialized, { level: 1 }, (error, deflated) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
writeFile(queueLocation, deflated, (writeError) => {
|
||||||
|
if (writeError) {
|
||||||
|
reject(writeError);
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('error saving queue state: ', error);
|
||||||
|
} finally {
|
||||||
|
mainWindow?.close();
|
||||||
|
if (forceQuit) {
|
||||||
|
app.exit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
mainWindow.on('minimize', (event: any) => {
|
mainWindow.on('minimize', (event: any) => {
|
||||||
@@ -308,7 +420,6 @@ const createWindow = async () => {
|
|||||||
app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling,MediaSessionService');
|
app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling,MediaSessionService');
|
||||||
|
|
||||||
const MPV_BINARY_PATH = store.get('mpv_path') as string | undefined;
|
const MPV_BINARY_PATH = store.get('mpv_path') as string | undefined;
|
||||||
const MPV_PARAMETERS = store.get('mpv_parameters') as Array<string> | undefined;
|
|
||||||
|
|
||||||
const prefetchPlaylistParams = [
|
const prefetchPlaylistParams = [
|
||||||
'--prefetch-playlist=no',
|
'--prefetch-playlist=no',
|
||||||
@@ -316,10 +427,10 @@ const prefetchPlaylistParams = [
|
|||||||
'--prefetch-playlist',
|
'--prefetch-playlist',
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_MPV_PARAMETERS = () => {
|
const DEFAULT_MPV_PARAMETERS = (extraParameters?: string[]) => {
|
||||||
const parameters = [];
|
const parameters = ['--idle=yes', '--no-config', '--load-scripts=no'];
|
||||||
|
|
||||||
if (!MPV_PARAMETERS?.some((param) => prefetchPlaylistParams.includes(param))) {
|
if (!extraParameters?.some((param) => prefetchPlaylistParams.includes(param))) {
|
||||||
parameters.push('--prefetch-playlist=yes');
|
parameters.push('--prefetch-playlist=yes');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,26 +442,39 @@ let mpvInstance: MpvAPI | null = null;
|
|||||||
const createMpv = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
|
const createMpv = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
|
||||||
const { extraParameters, properties } = data;
|
const { extraParameters, properties } = data;
|
||||||
|
|
||||||
mpvInstance = new MpvAPI(
|
const params = uniq([...DEFAULT_MPV_PARAMETERS(extraParameters), ...(extraParameters || [])]);
|
||||||
|
console.log('Setting mpv params: ', params);
|
||||||
|
|
||||||
|
const extra = isDevelopment ? '-dev' : '';
|
||||||
|
|
||||||
|
const mpv = new MpvAPI(
|
||||||
{
|
{
|
||||||
audio_only: true,
|
audio_only: true,
|
||||||
auto_restart: false,
|
auto_restart: false,
|
||||||
binary: MPV_BINARY_PATH || '',
|
binary: MPV_BINARY_PATH || '',
|
||||||
|
socket: isWindows() ? `\\\\.\\pipe\\mpvserver${extra}` : `/tmp/node-mpv${extra}.sock`,
|
||||||
time_update: 1,
|
time_update: 1,
|
||||||
},
|
},
|
||||||
MPV_PARAMETERS || extraParameters
|
params,
|
||||||
? uniq([...DEFAULT_MPV_PARAMETERS(), ...(MPV_PARAMETERS || []), ...(extraParameters || [])])
|
|
||||||
: DEFAULT_MPV_PARAMETERS(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
mpvInstance.setMultipleProperties(properties || {});
|
// eslint-disable-next-line promise/catch-or-return
|
||||||
|
mpv.start()
|
||||||
mpvInstance.start().catch((error) => {
|
.catch((error) => {
|
||||||
console.log('error starting mpv', error);
|
console.log('MPV failed to start', error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
console.log('Setting MPV properties: ', properties);
|
||||||
|
mpv.setMultipleProperties(properties || {});
|
||||||
});
|
});
|
||||||
|
|
||||||
mpvInstance.on('status', (status) => {
|
mpv.on('status', (status, ...rest) => {
|
||||||
|
console.log('MPV Event: status', status.property, status.value, rest);
|
||||||
if (status.property === 'playlist-pos') {
|
if (status.property === 'playlist-pos') {
|
||||||
|
if (status.value === -1) {
|
||||||
|
mpv?.stop();
|
||||||
|
}
|
||||||
|
|
||||||
if (status.value !== 0) {
|
if (status.value !== 0) {
|
||||||
getMainWindow()?.webContents.send('renderer-player-auto-next');
|
getMainWindow()?.webContents.send('renderer-player-auto-next');
|
||||||
}
|
}
|
||||||
@@ -358,24 +482,33 @@ const createMpv = (data: { extraParameters?: string[]; properties?: Record<strin
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Automatically updates the play button when the player is playing
|
// Automatically updates the play button when the player is playing
|
||||||
mpvInstance.on('resumed', () => {
|
mpv.on('resumed', () => {
|
||||||
|
console.log('MPV Event: resumed');
|
||||||
getMainWindow()?.webContents.send('renderer-player-play');
|
getMainWindow()?.webContents.send('renderer-player-play');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Automatically updates the play button when the player is stopped
|
// Automatically updates the play button when the player is stopped
|
||||||
mpvInstance.on('stopped', () => {
|
mpv.on('stopped', () => {
|
||||||
|
console.log('MPV Event: stopped');
|
||||||
getMainWindow()?.webContents.send('renderer-player-stop');
|
getMainWindow()?.webContents.send('renderer-player-stop');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Automatically updates the play button when the player is paused
|
// Automatically updates the play button when the player is paused
|
||||||
mpvInstance.on('paused', () => {
|
mpv.on('paused', () => {
|
||||||
|
console.log('MPV Event: paused');
|
||||||
getMainWindow()?.webContents.send('renderer-player-pause');
|
getMainWindow()?.webContents.send('renderer-player-pause');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Event output every interval set by time_update, used to update the current time
|
// Event output every interval set by time_update, used to update the current time
|
||||||
mpvInstance.on('timeposition', (time: number) => {
|
mpv.on('timeposition', (time: number) => {
|
||||||
getMainWindow()?.webContents.send('renderer-player-current-time', time);
|
getMainWindow()?.webContents.send('renderer-player-current-time', time);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mpv.on('quit', () => {
|
||||||
|
console.log('MPV Event: quit');
|
||||||
|
});
|
||||||
|
|
||||||
|
return mpv;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getMpvInstance = () => {
|
export const getMpvInstance = () => {
|
||||||
@@ -398,12 +531,109 @@ ipcMain.on(
|
|||||||
'player-restart',
|
'player-restart',
|
||||||
async (_event, data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
|
async (_event, data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
|
||||||
mpvInstance?.quit();
|
mpvInstance?.quit();
|
||||||
createMpv(data);
|
mpvInstance = createMpv(data);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ipcMain.on(
|
||||||
|
'player-initialize',
|
||||||
|
async (_event, data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
|
||||||
|
console.log('Initializing MPV with data: ', data);
|
||||||
|
mpvInstance = createMpv(data);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ipcMain.on('player-quit', async () => {
|
||||||
|
mpvInstance?.stop();
|
||||||
|
mpvInstance?.quit();
|
||||||
|
mpvInstance = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Must duplicate with the one in renderer process settings.store.ts
|
||||||
|
enum BindingActions {
|
||||||
|
GLOBAL_SEARCH = 'globalSearch',
|
||||||
|
LOCAL_SEARCH = 'localSearch',
|
||||||
|
MUTE = 'volumeMute',
|
||||||
|
NEXT = 'next',
|
||||||
|
PAUSE = 'pause',
|
||||||
|
PLAY = 'play',
|
||||||
|
PLAY_PAUSE = 'playPause',
|
||||||
|
PREVIOUS = 'previous',
|
||||||
|
SHUFFLE = 'toggleShuffle',
|
||||||
|
SKIP_BACKWARD = 'skipBackward',
|
||||||
|
SKIP_FORWARD = 'skipForward',
|
||||||
|
STOP = 'stop',
|
||||||
|
TOGGLE_FULLSCREEN_PLAYER = 'toggleFullscreenPlayer',
|
||||||
|
TOGGLE_QUEUE = 'toggleQueue',
|
||||||
|
TOGGLE_REPEAT = 'toggleRepeat',
|
||||||
|
VOLUME_DOWN = 'volumeDown',
|
||||||
|
VOLUME_UP = 'volumeUp',
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOTKEY_ACTIONS: Record<BindingActions, () => void> = {
|
||||||
|
[BindingActions.MUTE]: () => getMainWindow()?.webContents.send('renderer-player-volume-mute'),
|
||||||
|
[BindingActions.NEXT]: () => getMainWindow()?.webContents.send('renderer-player-next'),
|
||||||
|
[BindingActions.PAUSE]: () => getMainWindow()?.webContents.send('renderer-player-pause'),
|
||||||
|
[BindingActions.PLAY]: () => getMainWindow()?.webContents.send('renderer-player-play'),
|
||||||
|
[BindingActions.PLAY_PAUSE]: () =>
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-play-pause'),
|
||||||
|
[BindingActions.PREVIOUS]: () => getMainWindow()?.webContents.send('renderer-player-previous'),
|
||||||
|
[BindingActions.SHUFFLE]: () =>
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-toggle-shuffle'),
|
||||||
|
[BindingActions.SKIP_BACKWARD]: () =>
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-skip-backward'),
|
||||||
|
[BindingActions.SKIP_FORWARD]: () =>
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-skip-forward'),
|
||||||
|
[BindingActions.STOP]: () => getMainWindow()?.webContents.send('renderer-player-stop'),
|
||||||
|
[BindingActions.TOGGLE_REPEAT]: () =>
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-toggle-repeat'),
|
||||||
|
[BindingActions.VOLUME_UP]: () =>
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-volume-up'),
|
||||||
|
[BindingActions.VOLUME_DOWN]: () =>
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-volume-down'),
|
||||||
|
[BindingActions.GLOBAL_SEARCH]: () => {},
|
||||||
|
[BindingActions.LOCAL_SEARCH]: () => {},
|
||||||
|
[BindingActions.TOGGLE_QUEUE]: () => {},
|
||||||
|
[BindingActions.TOGGLE_FULLSCREEN_PLAYER]: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
ipcMain.on(
|
||||||
|
'set-global-shortcuts',
|
||||||
|
(
|
||||||
|
_event,
|
||||||
|
data: Record<BindingActions, { allowGlobal: boolean; hotkey: string; isGlobal: boolean }>,
|
||||||
|
) => {
|
||||||
|
// Since we're not tracking the previous shortcuts, we need to unregister all of them
|
||||||
|
globalShortcut.unregisterAll();
|
||||||
|
|
||||||
|
for (const shortcut of Object.keys(data)) {
|
||||||
|
const isGlobalHotkey = data[shortcut as BindingActions].isGlobal;
|
||||||
|
const isValidHotkey =
|
||||||
|
data[shortcut as BindingActions].hotkey &&
|
||||||
|
data[shortcut as BindingActions].hotkey !== '';
|
||||||
|
|
||||||
|
if (isGlobalHotkey && isValidHotkey) {
|
||||||
|
const accelerator = hotkeyToElectronAccelerator(
|
||||||
|
data[shortcut as BindingActions].hotkey,
|
||||||
|
);
|
||||||
|
|
||||||
|
globalShortcut.register(accelerator, () => {
|
||||||
|
HOTKEY_ACTIONS[shortcut as BindingActions]();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalMediaKeysEnabled = store.get('global_media_hotkeys') as boolean;
|
||||||
|
|
||||||
|
if (globalMediaKeysEnabled) {
|
||||||
|
enableMediaKeys(mainWindow);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
app.on('before-quit', () => {
|
app.on('before-quit', () => {
|
||||||
getMpvInstance()?.stop();
|
getMpvInstance()?.stop();
|
||||||
|
getMpvInstance()?.quit();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
@@ -418,8 +648,7 @@ app.on('window-all-closed', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app
|
app.whenReady()
|
||||||
.whenReady()
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
createWindow();
|
createWindow();
|
||||||
createTray();
|
createTray();
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ export default class MenuBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const template =
|
const template =
|
||||||
process.platform === 'darwin' ? this.buildDarwinTemplate() : this.buildDefaultTemplate();
|
process.platform === 'darwin'
|
||||||
|
? this.buildDarwinTemplate()
|
||||||
|
: this.buildDefaultTemplate();
|
||||||
|
|
||||||
const menu = Menu.buildFromTemplate(template);
|
const menu = Menu.buildFromTemplate(template);
|
||||||
Menu.setApplicationMenu(menu);
|
Menu.setApplicationMenu(menu);
|
||||||
@@ -151,7 +153,9 @@ export default class MenuBuilder {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
click() {
|
click() {
|
||||||
shell.openExternal('https://github.com/electron/electron/tree/main/docs#readme');
|
shell.openExternal(
|
||||||
|
'https://github.com/electron/electron/tree/main/docs#readme',
|
||||||
|
);
|
||||||
},
|
},
|
||||||
label: 'Documentation',
|
label: 'Documentation',
|
||||||
},
|
},
|
||||||
@@ -211,7 +215,9 @@ export default class MenuBuilder {
|
|||||||
{
|
{
|
||||||
accelerator: 'F11',
|
accelerator: 'F11',
|
||||||
click: () => {
|
click: () => {
|
||||||
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
|
this.mainWindow.setFullScreen(
|
||||||
|
!this.mainWindow.isFullScreen(),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
label: 'Toggle &Full Screen',
|
label: 'Toggle &Full Screen',
|
||||||
},
|
},
|
||||||
@@ -227,7 +233,9 @@ export default class MenuBuilder {
|
|||||||
{
|
{
|
||||||
accelerator: 'F11',
|
accelerator: 'F11',
|
||||||
click: () => {
|
click: () => {
|
||||||
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
|
this.mainWindow.setFullScreen(
|
||||||
|
!this.mainWindow.isFullScreen(),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
label: 'Toggle &Full Screen',
|
label: 'Toggle &Full Screen',
|
||||||
},
|
},
|
||||||
@@ -244,7 +252,9 @@ export default class MenuBuilder {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
click() {
|
click() {
|
||||||
shell.openExternal('https://github.com/electron/electron/tree/main/docs#readme');
|
shell.openExternal(
|
||||||
|
'https://github.com/electron/electron/tree/main/docs#readme',
|
||||||
|
);
|
||||||
},
|
},
|
||||||
label: 'Documentation',
|
label: 'Documentation',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,16 +2,20 @@ import { contextBridge } from 'electron';
|
|||||||
import { browser } from './preload/browser';
|
import { browser } from './preload/browser';
|
||||||
import { ipc } from './preload/ipc';
|
import { ipc } from './preload/ipc';
|
||||||
import { localSettings } from './preload/local-settings';
|
import { localSettings } from './preload/local-settings';
|
||||||
|
import { lyrics } from './preload/lyrics';
|
||||||
import { mpris } from './preload/mpris';
|
import { mpris } from './preload/mpris';
|
||||||
import { mpvPlayer, mpvPlayerListener } from './preload/mpv-player';
|
import { mpvPlayer, mpvPlayerListener } from './preload/mpv-player';
|
||||||
|
import { remote } from './preload/remote';
|
||||||
import { utils } from './preload/utils';
|
import { utils } from './preload/utils';
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electron', {
|
contextBridge.exposeInMainWorld('electron', {
|
||||||
browser,
|
browser,
|
||||||
ipc,
|
ipc,
|
||||||
localSettings,
|
localSettings,
|
||||||
|
lyrics,
|
||||||
mpris,
|
mpris,
|
||||||
mpvPlayer,
|
mpvPlayer,
|
||||||
mpvPlayerListener,
|
mpvPlayerListener,
|
||||||
|
remote,
|
||||||
utils,
|
utils,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ const removeAllListeners = (channel: string) => {
|
|||||||
ipcRenderer.removeAllListeners(channel);
|
ipcRenderer.removeAllListeners(channel);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const send = (channel: string, ...args: any[]) => {
|
||||||
|
ipcRenderer.send(channel, ...args);
|
||||||
|
};
|
||||||
|
|
||||||
export const ipc = {
|
export const ipc = {
|
||||||
removeAllListeners,
|
removeAllListeners,
|
||||||
|
send,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Ipc = typeof ipc;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ipcRenderer } from 'electron';
|
import { ipcRenderer, webFrame } from 'electron';
|
||||||
import Store from 'electron-store';
|
import Store from 'electron-store';
|
||||||
|
|
||||||
const store = new Store();
|
const store = new Store();
|
||||||
@@ -23,10 +23,32 @@ const disableMediaKeys = () => {
|
|||||||
ipcRenderer.send('global-media-keys-disable');
|
ipcRenderer.send('global-media-keys-disable');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const passwordGet = async (server: string): Promise<string | null> => {
|
||||||
|
return ipcRenderer.invoke('password-get', server);
|
||||||
|
};
|
||||||
|
|
||||||
|
const passwordRemove = (server: string) => {
|
||||||
|
ipcRenderer.send('password-remove', server);
|
||||||
|
};
|
||||||
|
|
||||||
|
const passwordSet = async (password: string, server: string): Promise<boolean> => {
|
||||||
|
return ipcRenderer.invoke('password-set', password, server);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setZoomFactor = (zoomFactor: number) => {
|
||||||
|
webFrame.setZoomFactor(zoomFactor / 100);
|
||||||
|
};
|
||||||
|
|
||||||
export const localSettings = {
|
export const localSettings = {
|
||||||
disableMediaKeys,
|
disableMediaKeys,
|
||||||
enableMediaKeys,
|
enableMediaKeys,
|
||||||
get,
|
get,
|
||||||
|
passwordGet,
|
||||||
|
passwordRemove,
|
||||||
|
passwordSet,
|
||||||
restart,
|
restart,
|
||||||
set,
|
set,
|
||||||
|
setZoomFactor,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type LocalSettings = typeof localSettings;
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { ipcRenderer } from 'electron';
|
||||||
|
import {
|
||||||
|
InternetProviderLyricSearchResponse,
|
||||||
|
LyricGetQuery,
|
||||||
|
LyricSearchQuery,
|
||||||
|
LyricSource,
|
||||||
|
QueueSong,
|
||||||
|
} from '/@/renderer/api/types';
|
||||||
|
|
||||||
|
const getRemoteLyricsBySong = (song: QueueSong) => {
|
||||||
|
const result = ipcRenderer.invoke('lyric-by-song', song);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchRemoteLyrics = (
|
||||||
|
params: LyricSearchQuery,
|
||||||
|
): Promise<Record<LyricSource, InternetProviderLyricSearchResponse[]>> => {
|
||||||
|
const result = ipcRenderer.invoke('lyric-search', params);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRemoteLyricsByRemoteId = (id: LyricGetQuery) => {
|
||||||
|
const result = ipcRenderer.invoke('lyric-by-remote-id', id);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const lyrics = {
|
||||||
|
getRemoteLyricsByRemoteId,
|
||||||
|
getRemoteLyricsBySong,
|
||||||
|
searchRemoteLyrics,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Lyrics = typeof lyrics;
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
import { IpcRendererEvent, ipcRenderer } from 'electron';
|
import { IpcRendererEvent, ipcRenderer } from 'electron';
|
||||||
import { QueueSong } from '/@/renderer/api/types';
|
import type { PlayerRepeat } from '/@/renderer/types';
|
||||||
|
|
||||||
const updateSong = (args: { currentTime: number; song: QueueSong }) => {
|
|
||||||
ipcRenderer.send('mpris-update-song', args);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatePosition = (timeSec: number) => {
|
const updatePosition = (timeSec: number) => {
|
||||||
ipcRenderer.send('mpris-update-position', timeSec);
|
ipcRenderer.send('mpris-update-position', timeSec);
|
||||||
@@ -13,18 +9,6 @@ const updateSeek = (timeSec: number) => {
|
|||||||
ipcRenderer.send('mpris-update-seek', timeSec);
|
ipcRenderer.send('mpris-update-seek', timeSec);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateVolume = (volume: number) => {
|
|
||||||
ipcRenderer.send('mpris-update-volume', volume);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateRepeat = (repeat: string) => {
|
|
||||||
ipcRenderer.send('mpris-update-repeat', repeat);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateShuffle = (shuffle: boolean) => {
|
|
||||||
ipcRenderer.send('mpris-update-shuffle', shuffle);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleRepeat = () => {
|
const toggleRepeat = () => {
|
||||||
ipcRenderer.send('mpris-toggle-repeat');
|
ipcRenderer.send('mpris-toggle-repeat');
|
||||||
};
|
};
|
||||||
@@ -33,38 +17,25 @@ const toggleShuffle = () => {
|
|||||||
ipcRenderer.send('mpris-toggle-shuffle');
|
ipcRenderer.send('mpris-toggle-shuffle');
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestPosition = (cb: (event: IpcRendererEvent, data: { position: number }) => void) => {
|
const requestToggleRepeat = (
|
||||||
ipcRenderer.on('mpris-request-position', cb);
|
cb: (event: IpcRendererEvent, data: { repeat: PlayerRepeat }) => void,
|
||||||
};
|
) => {
|
||||||
|
|
||||||
const requestSeek = (cb: (event: IpcRendererEvent, data: { offset: number }) => void) => {
|
|
||||||
ipcRenderer.on('mpris-request-seek', cb);
|
|
||||||
};
|
|
||||||
|
|
||||||
const requestVolume = (cb: (event: IpcRendererEvent, data: { volume: number }) => void) => {
|
|
||||||
ipcRenderer.on('mpris-request-volume', cb);
|
|
||||||
};
|
|
||||||
|
|
||||||
const requestToggleRepeat = (cb: (event: IpcRendererEvent) => void) => {
|
|
||||||
ipcRenderer.on('mpris-request-toggle-repeat', cb);
|
ipcRenderer.on('mpris-request-toggle-repeat', cb);
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestToggleShuffle = (cb: (event: IpcRendererEvent) => void) => {
|
const requestToggleShuffle = (
|
||||||
|
cb: (event: IpcRendererEvent, data: { shuffle: boolean }) => void,
|
||||||
|
) => {
|
||||||
ipcRenderer.on('mpris-request-toggle-shuffle', cb);
|
ipcRenderer.on('mpris-request-toggle-shuffle', cb);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mpris = {
|
export const mpris = {
|
||||||
requestPosition,
|
|
||||||
requestSeek,
|
|
||||||
requestToggleRepeat,
|
requestToggleRepeat,
|
||||||
requestToggleShuffle,
|
requestToggleShuffle,
|
||||||
requestVolume,
|
|
||||||
toggleRepeat,
|
toggleRepeat,
|
||||||
toggleShuffle,
|
toggleShuffle,
|
||||||
updatePosition,
|
updatePosition,
|
||||||
updateRepeat,
|
|
||||||
updateSeek,
|
updateSeek,
|
||||||
updateShuffle,
|
|
||||||
updateSong,
|
|
||||||
updateVolume,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Mpris = typeof mpris;
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
import { ipcRenderer, IpcRendererEvent } from 'electron';
|
import { ipcRenderer, IpcRendererEvent } from 'electron';
|
||||||
import { PlayerData } from '/@/renderer/store';
|
import { PlayerData, PlayerState } from '/@/renderer/store';
|
||||||
|
|
||||||
|
const initialize = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
|
||||||
|
ipcRenderer.send('player-initialize', data);
|
||||||
|
};
|
||||||
|
|
||||||
const restart = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
|
const restart = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
|
||||||
ipcRenderer.send('player-restart', data);
|
ipcRenderer.send('player-restart', data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isRunning = () => {
|
||||||
|
return ipcRenderer.invoke('player-is-running');
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
return ipcRenderer.invoke('player-clean-up');
|
||||||
|
};
|
||||||
|
|
||||||
const setProperties = (data: Record<string, any>) => {
|
const setProperties = (data: Record<string, any>) => {
|
||||||
console.log('Setting property :>>', data);
|
console.log('Setting property :>>', data);
|
||||||
ipcRenderer.send('player-set-properties', data);
|
ipcRenderer.send('player-set-properties', data);
|
||||||
@@ -18,8 +30,8 @@ const currentTime = () => {
|
|||||||
ipcRenderer.send('player-current-time');
|
ipcRenderer.send('player-current-time');
|
||||||
};
|
};
|
||||||
|
|
||||||
const mute = () => {
|
const mute = (mute: boolean) => {
|
||||||
ipcRenderer.send('player-mute');
|
ipcRenderer.send('player-mute', mute);
|
||||||
};
|
};
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
@@ -38,6 +50,14 @@ const previous = () => {
|
|||||||
ipcRenderer.send('player-previous');
|
ipcRenderer.send('player-previous');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const restoreQueue = () => {
|
||||||
|
ipcRenderer.send('player-restore-queue');
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveQueue = (data: Record<string, any>) => {
|
||||||
|
ipcRenderer.send('player-save-queue', data);
|
||||||
|
};
|
||||||
|
|
||||||
const seek = (seconds: number) => {
|
const seek = (seconds: number) => {
|
||||||
ipcRenderer.send('player-seek', seconds);
|
ipcRenderer.send('player-seek', seconds);
|
||||||
};
|
};
|
||||||
@@ -46,8 +66,8 @@ const seekTo = (seconds: number) => {
|
|||||||
ipcRenderer.send('player-seek-to', seconds);
|
ipcRenderer.send('player-seek-to', seconds);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setQueue = (data: PlayerData) => {
|
const setQueue = (data: PlayerData, pause?: boolean) => {
|
||||||
ipcRenderer.send('player-set-queue', data);
|
ipcRenderer.send('player-set-queue', data, pause);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setQueueNext = (data: PlayerData) => {
|
const setQueueNext = (data: PlayerData) => {
|
||||||
@@ -66,6 +86,10 @@ const quit = () => {
|
|||||||
ipcRenderer.send('player-quit');
|
ipcRenderer.send('player-quit');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getCurrentTime = async () => {
|
||||||
|
return ipcRenderer.invoke('player-get-time');
|
||||||
|
};
|
||||||
|
|
||||||
const rendererAutoNext = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
const rendererAutoNext = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||||
ipcRenderer.on('renderer-player-auto-next', cb);
|
ipcRenderer.on('renderer-player-auto-next', cb);
|
||||||
};
|
};
|
||||||
@@ -98,13 +122,59 @@ const rendererStop = (cb: (event: IpcRendererEvent, data: PlayerData) => void) =
|
|||||||
ipcRenderer.on('renderer-player-stop', cb);
|
ipcRenderer.on('renderer-player-stop', cb);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const rendererSkipForward = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||||
|
ipcRenderer.on('renderer-player-skip-forward', cb);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rendererSkipBackward = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||||
|
ipcRenderer.on('renderer-player-skip-backward', cb);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rendererVolumeUp = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||||
|
ipcRenderer.on('renderer-player-volume-up', cb);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rendererVolumeDown = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||||
|
ipcRenderer.on('renderer-player-volume-down', cb);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rendererVolumeMute = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||||
|
ipcRenderer.on('renderer-player-volume-mute', cb);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rendererToggleRepeat = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||||
|
ipcRenderer.on('renderer-player-toggle-repeat', cb);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rendererToggleShuffle = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||||
|
ipcRenderer.on('renderer-player-toggle-shuffle', cb);
|
||||||
|
};
|
||||||
|
|
||||||
const rendererQuit = (cb: (event: IpcRendererEvent) => void) => {
|
const rendererQuit = (cb: (event: IpcRendererEvent) => void) => {
|
||||||
ipcRenderer.on('renderer-player-quit', cb);
|
ipcRenderer.on('renderer-player-quit', cb);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const rendererSaveQueue = (cb: (event: IpcRendererEvent) => void) => {
|
||||||
|
ipcRenderer.on('renderer-player-save-queue', cb);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rendererRestoreQueue = (
|
||||||
|
cb: (event: IpcRendererEvent, data: Partial<PlayerState>) => void,
|
||||||
|
) => {
|
||||||
|
ipcRenderer.on('renderer-player-restore-queue', cb);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rendererError = (cb: (event: IpcRendererEvent, data: string) => void) => {
|
||||||
|
ipcRenderer.on('renderer-player-error', cb);
|
||||||
|
};
|
||||||
|
|
||||||
export const mpvPlayer = {
|
export const mpvPlayer = {
|
||||||
autoNext,
|
autoNext,
|
||||||
|
cleanup,
|
||||||
currentTime,
|
currentTime,
|
||||||
|
getCurrentTime,
|
||||||
|
initialize,
|
||||||
|
isRunning,
|
||||||
mute,
|
mute,
|
||||||
next,
|
next,
|
||||||
pause,
|
pause,
|
||||||
@@ -112,6 +182,8 @@ export const mpvPlayer = {
|
|||||||
previous,
|
previous,
|
||||||
quit,
|
quit,
|
||||||
restart,
|
restart,
|
||||||
|
restoreQueue,
|
||||||
|
saveQueue,
|
||||||
seek,
|
seek,
|
||||||
seekTo,
|
seekTo,
|
||||||
setProperties,
|
setProperties,
|
||||||
@@ -124,11 +196,24 @@ export const mpvPlayer = {
|
|||||||
export const mpvPlayerListener = {
|
export const mpvPlayerListener = {
|
||||||
rendererAutoNext,
|
rendererAutoNext,
|
||||||
rendererCurrentTime,
|
rendererCurrentTime,
|
||||||
|
rendererError,
|
||||||
rendererNext,
|
rendererNext,
|
||||||
rendererPause,
|
rendererPause,
|
||||||
rendererPlay,
|
rendererPlay,
|
||||||
rendererPlayPause,
|
rendererPlayPause,
|
||||||
rendererPrevious,
|
rendererPrevious,
|
||||||
rendererQuit,
|
rendererQuit,
|
||||||
|
rendererRestoreQueue,
|
||||||
|
rendererSaveQueue,
|
||||||
|
rendererSkipBackward,
|
||||||
|
rendererSkipForward,
|
||||||
rendererStop,
|
rendererStop,
|
||||||
|
rendererToggleRepeat,
|
||||||
|
rendererToggleShuffle,
|
||||||
|
rendererVolumeDown,
|
||||||
|
rendererVolumeMute,
|
||||||
|
rendererVolumeUp,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MpvPLayer = typeof mpvPlayer;
|
||||||
|
export type MpvPlayerListener = typeof mpvPlayerListener;
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { IpcRendererEvent, ipcRenderer } from 'electron';
|
||||||
|
import { SongUpdate } from '/@/renderer/types';
|
||||||
|
|
||||||
|
const requestFavorite = (
|
||||||
|
cb: (
|
||||||
|
event: IpcRendererEvent,
|
||||||
|
data: { favorite: boolean; id: string; serverId: string },
|
||||||
|
) => void,
|
||||||
|
) => {
|
||||||
|
ipcRenderer.on('request-favorite', cb);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestPosition = (cb: (event: IpcRendererEvent, data: { position: number }) => void) => {
|
||||||
|
ipcRenderer.on('request-position', cb);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestRating = (
|
||||||
|
cb: (event: IpcRendererEvent, data: { id: string; rating: number; serverId: string }) => void,
|
||||||
|
) => {
|
||||||
|
ipcRenderer.on('request-rating', cb);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestSeek = (cb: (event: IpcRendererEvent, data: { offset: number }) => void) => {
|
||||||
|
ipcRenderer.on('request-seek', cb);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestVolume = (cb: (event: IpcRendererEvent, data: { volume: number }) => void) => {
|
||||||
|
ipcRenderer.on('request-volume', cb);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setRemoteEnabled = (enabled: boolean): Promise<string | null> => {
|
||||||
|
const result = ipcRenderer.invoke('remote-enable', enabled);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setRemotePort = (port: number): Promise<string | null> => {
|
||||||
|
const result = ipcRenderer.invoke('remote-port', port);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateFavorite = (favorite: boolean, serverId: string, ids: string[]) => {
|
||||||
|
ipcRenderer.send('update-favorite', favorite, serverId, ids);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePassword = (password: string) => {
|
||||||
|
ipcRenderer.send('remote-password', password);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSetting = (
|
||||||
|
enabled: boolean,
|
||||||
|
port: number,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<string | null> => {
|
||||||
|
return ipcRenderer.invoke('remote-settings', enabled, port, username, password);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateRating = (rating: number, serverId: string, ids: string[]) => {
|
||||||
|
ipcRenderer.send('update-rating', rating, serverId, ids);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateRepeat = (repeat: string) => {
|
||||||
|
ipcRenderer.send('update-repeat', repeat);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateShuffle = (shuffle: boolean) => {
|
||||||
|
ipcRenderer.send('update-shuffle', shuffle);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSong = (args: SongUpdate) => {
|
||||||
|
ipcRenderer.send('update-song', args);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateUsername = (username: string) => {
|
||||||
|
ipcRenderer.send('remote-username', username);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateVolume = (volume: number) => {
|
||||||
|
ipcRenderer.send('update-volume', volume);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const remote = {
|
||||||
|
requestFavorite,
|
||||||
|
requestPosition,
|
||||||
|
requestRating,
|
||||||
|
requestSeek,
|
||||||
|
requestVolume,
|
||||||
|
setRemoteEnabled,
|
||||||
|
setRemotePort,
|
||||||
|
updateFavorite,
|
||||||
|
updatePassword,
|
||||||
|
updateRating,
|
||||||
|
updateRepeat,
|
||||||
|
updateSetting,
|
||||||
|
updateShuffle,
|
||||||
|
updateSong,
|
||||||
|
updateUsername,
|
||||||
|
updateVolume,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Remote = typeof remote;
|
||||||
@@ -5,3 +5,5 @@ export const utils = {
|
|||||||
isMacOS,
|
isMacOS,
|
||||||
isWindows,
|
isWindows,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Utils = typeof utils;
|
||||||
|
|||||||
@@ -29,3 +29,24 @@ export const isWindows = () => {
|
|||||||
export const isLinux = () => {
|
export const isLinux = () => {
|
||||||
return process.platform === 'linux';
|
return process.platform === 'linux';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const hotkeyToElectronAccelerator = (hotkey: string) => {
|
||||||
|
let accelerator = hotkey;
|
||||||
|
|
||||||
|
const replacements = {
|
||||||
|
mod: 'CmdOrCtrl',
|
||||||
|
numpad: 'num',
|
||||||
|
numpadadd: 'numadd',
|
||||||
|
numpaddecimal: 'numdec',
|
||||||
|
numpaddivide: 'numdiv',
|
||||||
|
numpadenter: 'numenter',
|
||||||
|
numpadmultiply: 'nummult',
|
||||||
|
numpadsubtract: 'numsub',
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.keys(replacements).forEach((key) => {
|
||||||
|
accelerator = accelerator.replace(key, replacements[key as keyof typeof replacements]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return accelerator;
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import './styles/global.scss';
|
||||||
|
import { useIsDark, useReconnect } from '/@/remote/store';
|
||||||
|
import { Shell } from '/@/remote/components/shell';
|
||||||
|
|
||||||
|
export const App = () => {
|
||||||
|
const isDark = useIsDark();
|
||||||
|
const reconnect = useReconnect();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reconnect();
|
||||||
|
}, [reconnect]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MantineProvider
|
||||||
|
withGlobalStyles
|
||||||
|
withNormalizeCSS
|
||||||
|
theme={{
|
||||||
|
colorScheme: isDark ? 'dark' : 'light',
|
||||||
|
components: {
|
||||||
|
AppShell: {
|
||||||
|
styles: {
|
||||||
|
body: {
|
||||||
|
height: '100vh',
|
||||||
|
overflow: 'scroll',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Modal: {
|
||||||
|
styles: {
|
||||||
|
body: {
|
||||||
|
background: 'var(--modal-bg)',
|
||||||
|
height: '100vh',
|
||||||
|
},
|
||||||
|
close: { marginRight: '0.5rem' },
|
||||||
|
content: { borderRadius: '5px' },
|
||||||
|
header: {
|
||||||
|
background: 'var(--modal-header-bg)',
|
||||||
|
paddingBottom: '1rem',
|
||||||
|
},
|
||||||
|
title: { fontSize: 'medium', fontWeight: 500 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultRadius: 'xs',
|
||||||
|
dir: 'ltr',
|
||||||
|
focusRing: 'auto',
|
||||||
|
focusRingStyles: {
|
||||||
|
inputStyles: () => ({
|
||||||
|
border: '1px solid var(--primary-color)',
|
||||||
|
}),
|
||||||
|
resetStyles: () => ({ outline: 'none' }),
|
||||||
|
styles: () => ({
|
||||||
|
outline: '1px solid var(--primary-color)',
|
||||||
|
outlineOffset: '-1px',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
fontFamily: 'var(--content-font-family)',
|
||||||
|
fontSizes: {
|
||||||
|
lg: '1.1rem',
|
||||||
|
md: '1rem',
|
||||||
|
sm: '0.9rem',
|
||||||
|
xl: '1.5rem',
|
||||||
|
xs: '0.8rem',
|
||||||
|
},
|
||||||
|
headings: {
|
||||||
|
fontFamily: 'var(--content-font-family)',
|
||||||
|
fontWeight: 700,
|
||||||
|
},
|
||||||
|
other: {},
|
||||||
|
spacing: {
|
||||||
|
lg: '2rem',
|
||||||
|
md: '1rem',
|
||||||
|
sm: '0.5rem',
|
||||||
|
xl: '4rem',
|
||||||
|
xs: '0rem',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Shell />
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { CiImageOff, CiImageOn } from 'react-icons/ci';
|
||||||
|
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
|
||||||
|
import { useShowImage, useToggleShowImage } from '/@/remote/store';
|
||||||
|
|
||||||
|
export const ImageButton = () => {
|
||||||
|
const showImage = useShowImage();
|
||||||
|
const toggleImage = useToggleShowImage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RemoteButton
|
||||||
|
mr={5}
|
||||||
|
size="xl"
|
||||||
|
tooltip={showImage ? 'Hide Image' : 'Show Image'}
|
||||||
|
variant="default"
|
||||||
|
onClick={() => toggleImage()}
|
||||||
|
>
|
||||||
|
{showImage ? <CiImageOff size={30} /> : <CiImageOn size={30} />}
|
||||||
|
</RemoteButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
|
||||||
|
import { useConnected, useReconnect } from '/@/remote/store';
|
||||||
|
import { RiRestartLine } from 'react-icons/ri';
|
||||||
|
|
||||||
|
export const ReconnectButton = () => {
|
||||||
|
const connected = useConnected();
|
||||||
|
const reconnect = useReconnect();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RemoteButton
|
||||||
|
$active={!connected}
|
||||||
|
mr={5}
|
||||||
|
size="xl"
|
||||||
|
tooltip={connected ? 'Reconnect' : 'Not connected. Reconnect.'}
|
||||||
|
variant="default"
|
||||||
|
onClick={() => reconnect()}
|
||||||
|
>
|
||||||
|
<RiRestartLine size={30} />
|
||||||
|
</RemoteButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { MouseEvent, ReactNode, Ref, forwardRef } from 'react';
|
||||||
|
import { Button, type ButtonProps as MantineButtonProps } from '@mantine/core';
|
||||||
|
import { Tooltip } from '/@/renderer/components/tooltip';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
interface StyledButtonProps extends MantineButtonProps {
|
||||||
|
$active?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
onClick?: (e: MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||||
|
onMouseDown?: (e: MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||||
|
ref: Ref<HTMLButtonElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ButtonProps extends StyledButtonProps {
|
||||||
|
tooltip: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyledButton = styled(Button)<StyledButtonProps>`
|
||||||
|
svg {
|
||||||
|
display: flex;
|
||||||
|
fill: ${({ $active: active }) =>
|
||||||
|
active ? 'var(--primary-color)' : 'var(--playerbar-btn-fg)'};
|
||||||
|
stroke: var(--playerbar-btn-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--playerbar-btn-bg-hover);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: ${({ $active: active }) =>
|
||||||
|
active
|
||||||
|
? 'var(--primary-color) !important'
|
||||||
|
: 'var(--playerbar-btn-fg-hover) !important'};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const RemoteButton = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ children, tooltip, ...props }: ButtonProps, ref) => {
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
withinPortal
|
||||||
|
label={tooltip}
|
||||||
|
>
|
||||||
|
<StyledButton
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</StyledButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
RemoteButton.defaultProps = {
|
||||||
|
$active: false,
|
||||||
|
onClick: undefined,
|
||||||
|
onMouseDown: undefined,
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useIsDark, useToggleDark } from '/@/remote/store';
|
||||||
|
import { RiMoonLine, RiSunLine } from 'react-icons/ri';
|
||||||
|
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
|
||||||
|
import { AppTheme } from '/@/renderer/themes/types';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export const ThemeButton = () => {
|
||||||
|
const isDark = useIsDark();
|
||||||
|
const toggleDark = useToggleDark();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const targetTheme: AppTheme = isDark ? AppTheme.DEFAULT_DARK : AppTheme.DEFAULT_LIGHT;
|
||||||
|
document.body.setAttribute('data-theme', targetTheme);
|
||||||
|
}, [isDark]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RemoteButton
|
||||||
|
mr={5}
|
||||||
|
size="xl"
|
||||||
|
tooltip="Toggle Theme"
|
||||||
|
variant="default"
|
||||||
|
onClick={() => toggleDark()}
|
||||||
|
>
|
||||||
|
{isDark ? <RiSunLine size={30} /> : <RiMoonLine size={30} />}
|
||||||
|
</RemoteButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { Group, Image, Rating, Text, Title } from '@mantine/core';
|
||||||
|
import { useInfo, useSend, useShowImage } from '/@/remote/store';
|
||||||
|
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
|
||||||
|
import formatDuration from 'format-duration';
|
||||||
|
import debounce from 'lodash/debounce';
|
||||||
|
import {
|
||||||
|
RiHeartLine,
|
||||||
|
RiPauseFill,
|
||||||
|
RiPlayFill,
|
||||||
|
RiRepeat2Line,
|
||||||
|
RiRepeatOneLine,
|
||||||
|
RiShuffleFill,
|
||||||
|
RiSkipBackFill,
|
||||||
|
RiSkipForwardFill,
|
||||||
|
RiVolumeUpFill,
|
||||||
|
} from 'react-icons/ri';
|
||||||
|
import { PlayerRepeat, PlayerStatus } from '/@/renderer/types';
|
||||||
|
import { WrapperSlider } from '/@/remote/components/wrapped-slider';
|
||||||
|
import { Tooltip } from '/@/renderer/components/tooltip';
|
||||||
|
|
||||||
|
export const RemoteContainer = () => {
|
||||||
|
const { repeat, shuffle, song, status, volume } = useInfo();
|
||||||
|
const send = useSend();
|
||||||
|
const showImage = useShowImage();
|
||||||
|
|
||||||
|
const id = song?.id;
|
||||||
|
|
||||||
|
const setRating = useCallback(
|
||||||
|
(rating: number) => {
|
||||||
|
send({ event: 'rating', id: id!, rating });
|
||||||
|
},
|
||||||
|
[send, id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const debouncedSetRating = debounce(setRating, 400);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{song && (
|
||||||
|
<>
|
||||||
|
<Title order={1}>{song.name}</Title>
|
||||||
|
<Group align="flex-end">
|
||||||
|
<Title order={2}>Album: {song.album}</Title>
|
||||||
|
<Title order={2}>Artist: {song.artistName}</Title>
|
||||||
|
</Group>
|
||||||
|
<Group position="apart">
|
||||||
|
<Title order={3}>Duration: {formatDuration(song.duration)}</Title>
|
||||||
|
{song.releaseDate && (
|
||||||
|
<Title order={3}>
|
||||||
|
Released: {new Date(song.releaseDate).toLocaleDateString()}
|
||||||
|
</Title>
|
||||||
|
)}
|
||||||
|
<Title order={3}>Plays: {song.playCount}</Title>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Group
|
||||||
|
grow
|
||||||
|
spacing={0}
|
||||||
|
>
|
||||||
|
<RemoteButton
|
||||||
|
tooltip="Previous track"
|
||||||
|
variant="default"
|
||||||
|
onClick={() => send({ event: 'previous' })}
|
||||||
|
>
|
||||||
|
<RiSkipBackFill size={25} />
|
||||||
|
</RemoteButton>
|
||||||
|
<RemoteButton
|
||||||
|
tooltip={status === PlayerStatus.PLAYING ? 'Pause' : 'Play'}
|
||||||
|
variant="default"
|
||||||
|
onClick={() => {
|
||||||
|
if (status === PlayerStatus.PLAYING) {
|
||||||
|
send({ event: 'pause' });
|
||||||
|
} else if (status === PlayerStatus.PAUSED) {
|
||||||
|
send({ event: 'play' });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status === PlayerStatus.PLAYING ? (
|
||||||
|
<RiPauseFill size={25} />
|
||||||
|
) : (
|
||||||
|
<RiPlayFill size={25} />
|
||||||
|
)}
|
||||||
|
</RemoteButton>
|
||||||
|
<RemoteButton
|
||||||
|
tooltip="Next track"
|
||||||
|
variant="default"
|
||||||
|
onClick={() => send({ event: 'next' })}
|
||||||
|
>
|
||||||
|
<RiSkipForwardFill size={25} />
|
||||||
|
</RemoteButton>
|
||||||
|
</Group>
|
||||||
|
<Group
|
||||||
|
grow
|
||||||
|
spacing={0}
|
||||||
|
>
|
||||||
|
<RemoteButton
|
||||||
|
$active={shuffle || false}
|
||||||
|
tooltip={shuffle ? 'Shuffle tracks' : 'Shuffle disabled'}
|
||||||
|
variant="default"
|
||||||
|
onClick={() => send({ event: 'shuffle' })}
|
||||||
|
>
|
||||||
|
<RiShuffleFill size={25} />
|
||||||
|
</RemoteButton>
|
||||||
|
<RemoteButton
|
||||||
|
$active={repeat !== undefined && repeat !== PlayerRepeat.NONE}
|
||||||
|
tooltip={`Repeat ${
|
||||||
|
repeat === PlayerRepeat.ONE
|
||||||
|
? 'One'
|
||||||
|
: repeat === PlayerRepeat.ALL
|
||||||
|
? 'all'
|
||||||
|
: 'none'
|
||||||
|
}`}
|
||||||
|
variant="default"
|
||||||
|
onClick={() => send({ event: 'repeat' })}
|
||||||
|
>
|
||||||
|
{repeat === undefined || repeat === PlayerRepeat.ONE ? (
|
||||||
|
<RiRepeatOneLine size={25} />
|
||||||
|
) : (
|
||||||
|
<RiRepeat2Line size={25} />
|
||||||
|
)}
|
||||||
|
</RemoteButton>
|
||||||
|
<RemoteButton
|
||||||
|
$active={song?.userFavorite}
|
||||||
|
disabled={!song}
|
||||||
|
tooltip={song?.userFavorite ? 'Unfavorite' : 'Favorite'}
|
||||||
|
variant="default"
|
||||||
|
onClick={() => {
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
send({ event: 'favorite', favorite: !song.userFavorite, id });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiHeartLine size={25} />
|
||||||
|
</RemoteButton>
|
||||||
|
{(song?.serverType === 'navidrome' || song?.serverType === 'subsonic') && (
|
||||||
|
<div style={{ margin: 'auto' }}>
|
||||||
|
<Tooltip
|
||||||
|
label="Double click to clear"
|
||||||
|
openDelay={1000}
|
||||||
|
>
|
||||||
|
<Rating
|
||||||
|
sx={{ margin: 'auto' }}
|
||||||
|
value={song.userRating ?? 0}
|
||||||
|
onChange={debouncedSetRating}
|
||||||
|
onDoubleClick={() => debouncedSetRating(0)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<WrapperSlider
|
||||||
|
leftLabel={<RiVolumeUpFill size={20} />}
|
||||||
|
max={100}
|
||||||
|
rightLabel={
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
weight={600}
|
||||||
|
>
|
||||||
|
{volume ?? 0}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
value={volume ?? 0}
|
||||||
|
onChangeEnd={(e) => send({ event: 'volume', volume: e })}
|
||||||
|
/>
|
||||||
|
{showImage && (
|
||||||
|
<Image
|
||||||
|
src={song?.imageUrl?.replaceAll(/&(size|width|height=\d+)/g, '')}
|
||||||
|
onError={() => send({ event: 'proxy' })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import {
|
||||||
|
AppShell,
|
||||||
|
Container,
|
||||||
|
Flex,
|
||||||
|
Grid,
|
||||||
|
Header,
|
||||||
|
Image,
|
||||||
|
MediaQuery,
|
||||||
|
Skeleton,
|
||||||
|
Title,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { ThemeButton } from '/@/remote/components/buttons/theme-button';
|
||||||
|
import { ImageButton } from '/@/remote/components/buttons/image-button';
|
||||||
|
import { RemoteContainer } from '/@/remote/components/remote-container';
|
||||||
|
import { ReconnectButton } from '/@/remote/components/buttons/reconnect-button';
|
||||||
|
import { useConnected } from '/@/remote/store';
|
||||||
|
|
||||||
|
export const Shell = () => {
|
||||||
|
const connected = useConnected();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
header={
|
||||||
|
<Header height={60}>
|
||||||
|
<Grid>
|
||||||
|
<Grid.Col span="auto">
|
||||||
|
<div>
|
||||||
|
<Image
|
||||||
|
fit="contain"
|
||||||
|
height={60}
|
||||||
|
src="/favicon.ico"
|
||||||
|
width={60}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Grid.Col>
|
||||||
|
<MediaQuery
|
||||||
|
smallerThan="sm"
|
||||||
|
styles={{ display: 'none' }}
|
||||||
|
>
|
||||||
|
<Grid.Col
|
||||||
|
sm={6}
|
||||||
|
xs={0}
|
||||||
|
>
|
||||||
|
<Title ta="center">Feishin Remote</Title>
|
||||||
|
</Grid.Col>
|
||||||
|
</MediaQuery>
|
||||||
|
|
||||||
|
<Grid.Col span="auto">
|
||||||
|
<Flex
|
||||||
|
direction="row"
|
||||||
|
justify="right"
|
||||||
|
>
|
||||||
|
<ReconnectButton />
|
||||||
|
<ImageButton />
|
||||||
|
<ThemeButton />
|
||||||
|
</Flex>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</Header>
|
||||||
|
}
|
||||||
|
padding="md"
|
||||||
|
>
|
||||||
|
<Container>
|
||||||
|
{connected ? (
|
||||||
|
<RemoteContainer />
|
||||||
|
) : (
|
||||||
|
<Skeleton
|
||||||
|
height={300}
|
||||||
|
width="100%"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useState, ReactNode } from 'react';
|
||||||
|
import { SliderProps } from '@mantine/core';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
|
||||||
|
|
||||||
|
const SliderContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
width: 95%;
|
||||||
|
height: 20px;
|
||||||
|
margin: 10px 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SliderValueWrapper = styled.div<{ $position: 'left' | 'right' }>`
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-self: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 50px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SliderWrapper = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex: 6;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export interface WrappedProps extends Omit<SliderProps, 'onChangeEnd'> {
|
||||||
|
leftLabel?: ReactNode;
|
||||||
|
onChangeEnd: (value: number) => void;
|
||||||
|
rightLabel?: ReactNode;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WrapperSlider = ({ leftLabel, rightLabel, value, ...props }: WrappedProps) => {
|
||||||
|
const [isSeeking, setIsSeeking] = useState(false);
|
||||||
|
const [seek, setSeek] = useState(0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SliderContainer>
|
||||||
|
{leftLabel && <SliderValueWrapper $position="left">{leftLabel}</SliderValueWrapper>}
|
||||||
|
<SliderWrapper>
|
||||||
|
<PlayerbarSlider
|
||||||
|
{...props}
|
||||||
|
min={0}
|
||||||
|
size={6}
|
||||||
|
value={!isSeeking ? value ?? 0 : seek}
|
||||||
|
w="100%"
|
||||||
|
onChange={(e) => {
|
||||||
|
setIsSeeking(true);
|
||||||
|
setSeek(e);
|
||||||
|
}}
|
||||||
|
onChangeEnd={(e) => {
|
||||||
|
props.onChangeEnd(e);
|
||||||
|
setIsSeeking(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SliderWrapper>
|
||||||
|
{rightLabel && <SliderValueWrapper $position="right">{rightLabel}</SliderValueWrapper>}
|
||||||
|
</SliderContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta http-equiv="Content-Security-Policy" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Feishin Remote</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Notifications } from '@mantine/notifications';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { App } from '/@/remote/app';
|
||||||
|
|
||||||
|
const container = document.getElementById('root')! as HTMLElement;
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
root.render(
|
||||||
|
<>
|
||||||
|
<Notifications
|
||||||
|
containerWidth="300px"
|
||||||
|
position="bottom-center"
|
||||||
|
/>
|
||||||
|
<App />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { hideNotification, showNotification } from '@mantine/notifications';
|
||||||
|
import type { NotificationProps as MantineNotificationProps } from '@mantine/notifications';
|
||||||
|
import merge from 'lodash/merge';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { devtools, persist } from 'zustand/middleware';
|
||||||
|
import { immer } from 'zustand/middleware/immer';
|
||||||
|
import { ClientEvent, ServerEvent, SongUpdateSocket } from '/@/remote/types';
|
||||||
|
|
||||||
|
interface StatefulWebSocket extends WebSocket {
|
||||||
|
natural: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsState {
|
||||||
|
connected: boolean;
|
||||||
|
info: Omit<SongUpdateSocket, 'currentTime'>;
|
||||||
|
isDark: boolean;
|
||||||
|
showImage: boolean;
|
||||||
|
socket?: StatefulWebSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsSlice extends SettingsState {
|
||||||
|
actions: {
|
||||||
|
reconnect: () => void;
|
||||||
|
send: (data: ClientEvent) => void;
|
||||||
|
toggleIsDark: () => void;
|
||||||
|
toggleShowImage: () => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: SettingsState = {
|
||||||
|
connected: false,
|
||||||
|
info: {},
|
||||||
|
isDark: window.matchMedia('(prefers-color-scheme: dark)').matches,
|
||||||
|
showImage: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface NotificationProps extends MantineNotificationProps {
|
||||||
|
type?: 'error' | 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
const showToast = ({ type, ...props }: NotificationProps) => {
|
||||||
|
const color = type === 'warning' ? 'var(--warning-color)' : 'var(--danger-color)';
|
||||||
|
|
||||||
|
const defaultTitle = type === 'warning' ? 'Warning' : 'Error';
|
||||||
|
|
||||||
|
const defaultDuration = type === 'error' ? 2000 : 1000;
|
||||||
|
|
||||||
|
return showNotification({
|
||||||
|
autoClose: defaultDuration,
|
||||||
|
styles: () => ({
|
||||||
|
closeButton: {
|
||||||
|
'&:hover': {
|
||||||
|
background: 'transparent',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
color: 'var(--toast-description-fg)',
|
||||||
|
fontSize: '1rem',
|
||||||
|
},
|
||||||
|
loader: {
|
||||||
|
margin: '1rem',
|
||||||
|
},
|
||||||
|
root: {
|
||||||
|
'&::before': { backgroundColor: color },
|
||||||
|
background: 'var(--toast-bg)',
|
||||||
|
border: '2px solid var(--generic-border-color)',
|
||||||
|
bottom: '90px',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: 'var(--toast-title-fg)',
|
||||||
|
fontSize: '1.3rem',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
title: defaultTitle,
|
||||||
|
...props,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toast = {
|
||||||
|
error: (props: NotificationProps) => showToast({ type: 'error', ...props }),
|
||||||
|
hide: hideNotification,
|
||||||
|
warn: (props: NotificationProps) => showToast({ type: 'warning', ...props }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRemoteStore = create<SettingsSlice>()(
|
||||||
|
persist(
|
||||||
|
devtools(
|
||||||
|
immer((set, get) => ({
|
||||||
|
actions: {
|
||||||
|
reconnect: async () => {
|
||||||
|
const existing = get().socket;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (
|
||||||
|
existing.readyState === WebSocket.OPEN ||
|
||||||
|
existing.readyState === WebSocket.CONNECTING
|
||||||
|
) {
|
||||||
|
existing.natural = true;
|
||||||
|
existing.close(4001);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let authHeader: string | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const credentials = await fetch('/credentials');
|
||||||
|
authHeader = await credentials.text();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
set((state) => {
|
||||||
|
const socket = new WebSocket(
|
||||||
|
// eslint-disable-next-line no-restricted-globals
|
||||||
|
location.href.replace('http', 'ws'),
|
||||||
|
) as StatefulWebSocket;
|
||||||
|
|
||||||
|
socket.natural = false;
|
||||||
|
|
||||||
|
socket.addEventListener('message', (message) => {
|
||||||
|
const { event, data } = JSON.parse(message.data) as ServerEvent;
|
||||||
|
|
||||||
|
switch (event) {
|
||||||
|
case 'error': {
|
||||||
|
toast.error({ message: data, title: 'Socket error' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'favorite': {
|
||||||
|
set((state) => {
|
||||||
|
if (state.info.song?.id === data.id) {
|
||||||
|
state.info.song.userFavorite = data.favorite;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'proxy': {
|
||||||
|
set((state) => {
|
||||||
|
if (state.info.song) {
|
||||||
|
state.info.song.imageUrl = `data:image/jpeg;base64,${data}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'rating': {
|
||||||
|
set((state) => {
|
||||||
|
if (state.info.song?.id === data.id) {
|
||||||
|
state.info.song.userRating = data.rating;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'song': {
|
||||||
|
set((nested) => {
|
||||||
|
nested.info = { ...nested.info, ...data };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener('open', () => {
|
||||||
|
if (authHeader) {
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
event: 'authenticate',
|
||||||
|
header: authHeader,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
set({ connected: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener('close', (reason) => {
|
||||||
|
if (reason.code === 4002 || reason.code === 4003) {
|
||||||
|
// eslint-disable-next-line no-restricted-globals
|
||||||
|
location.reload();
|
||||||
|
} else if (reason.code === 4000) {
|
||||||
|
toast.warn({
|
||||||
|
message: 'Feishin remote server is down',
|
||||||
|
title: 'Connection closed',
|
||||||
|
});
|
||||||
|
} else if (reason.code !== 4001 && !socket.natural) {
|
||||||
|
toast.error({
|
||||||
|
message: 'Socket closed for unexpected reason',
|
||||||
|
title: 'Connection closed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!socket.natural) {
|
||||||
|
set({ connected: false, info: {} });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
state.socket = socket;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
send: (data: ClientEvent) => {
|
||||||
|
console.log(data, get().socket);
|
||||||
|
get().socket?.send(JSON.stringify(data));
|
||||||
|
},
|
||||||
|
toggleIsDark: () => {
|
||||||
|
set((state) => {
|
||||||
|
state.isDark = !state.isDark;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
toggleShowImage: () => {
|
||||||
|
set((state) => {
|
||||||
|
state.showImage = !state.showImage;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...initialState,
|
||||||
|
})),
|
||||||
|
{ name: 'store_settings' },
|
||||||
|
),
|
||||||
|
{
|
||||||
|
merge: (persistedState, currentState) => {
|
||||||
|
return merge(currentState, persistedState);
|
||||||
|
},
|
||||||
|
name: 'store_settings',
|
||||||
|
version: 6,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useConnected = () => useRemoteStore((state) => state.connected);
|
||||||
|
|
||||||
|
export const useInfo = () => useRemoteStore((state) => state.info);
|
||||||
|
|
||||||
|
export const useIsDark = () => useRemoteStore((state) => state.isDark);
|
||||||
|
|
||||||
|
export const useReconnect = () => useRemoteStore((state) => state.actions.reconnect);
|
||||||
|
|
||||||
|
export const useShowImage = () => useRemoteStore((state) => state.showImage);
|
||||||
|
|
||||||
|
export const useSend = () => useRemoteStore((state) => state.actions.send);
|
||||||
|
|
||||||
|
export const useToggleDark = () => useRemoteStore((state) => state.actions.toggleIsDark);
|
||||||
|
|
||||||
|
export const useToggleShowImage = () => useRemoteStore((state) => state.actions.toggleShowImage);
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
@use '../../renderer/themes/default.scss';
|
||||||
|
@use '../../renderer/themes/dark.scss';
|
||||||
|
@use '../../renderer/themes/light.scss';
|
||||||
|
@use '../../renderer/styles/ag-grid.scss';
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body,
|
||||||
|
html {
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: hidden;
|
||||||
|
color: var(--content-text-color);
|
||||||
|
background: var(--content-bg);
|
||||||
|
font-family: var(--content-font-family);
|
||||||
|
font-size: var(--root-font-size);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 639px) {
|
||||||
|
body,
|
||||||
|
html {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*:before,
|
||||||
|
*:after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||||
|
-webkit-text-size-adjust: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-corner {
|
||||||
|
background: var(--scrollbar-track-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--scrollbar-track-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--scrollbar-thumb-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--scrollbar-thumb-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-scrollbar {
|
||||||
|
overflow-y: overlay !important;
|
||||||
|
overflow-x: overlay !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hide-scrollbar {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: transparent transparent;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hide-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none; /* Safari and Chrome */
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mantine-ScrollArea-thumb[data-state='visible'] {
|
||||||
|
animation: fadeIn 0.3s forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mantine-ScrollArea-scrollbar[data-state='hidden'] {
|
||||||
|
animation: fadeOut 0.2s forwards;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { QueueSong } from '/@/renderer/api/types';
|
||||||
|
import type { SongUpdate } from '/@/renderer/types';
|
||||||
|
|
||||||
|
export interface SongUpdateSocket extends Omit<SongUpdate, 'song'> {
|
||||||
|
song?: QueueSong | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerError {
|
||||||
|
data: string;
|
||||||
|
event: 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerFavorite {
|
||||||
|
data: { favorite: boolean; id: string };
|
||||||
|
event: 'favorite';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerProxy {
|
||||||
|
data: string;
|
||||||
|
event: 'proxy';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerRating {
|
||||||
|
data: { id: string; rating: number };
|
||||||
|
event: 'rating';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerSong {
|
||||||
|
data: SongUpdateSocket;
|
||||||
|
event: 'song';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerEvent = ServerError | ServerFavorite | ServerRating | ServerSong | ServerProxy;
|
||||||
|
|
||||||
|
export interface ClientSimpleEvent {
|
||||||
|
event: 'next' | 'pause' | 'play' | 'previous' | 'proxy' | 'repeat' | 'shuffle';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientFavorite {
|
||||||
|
event: 'favorite';
|
||||||
|
favorite: boolean;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientRating {
|
||||||
|
event: 'rating';
|
||||||
|
id: string;
|
||||||
|
rating: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientVolume {
|
||||||
|
event: 'volume';
|
||||||
|
volume: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientAuth {
|
||||||
|
event: 'authenticate';
|
||||||
|
header: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClientEvent =
|
||||||
|
| ClientAuth
|
||||||
|
| ClientSimpleEvent
|
||||||
|
| ClientFavorite
|
||||||
|
| ClientRating
|
||||||
|
| ClientVolume;
|
||||||
@@ -44,9 +44,13 @@ import type {
|
|||||||
UpdatePlaylistResponse,
|
UpdatePlaylistResponse,
|
||||||
UserListResponse,
|
UserListResponse,
|
||||||
AuthenticationResponse,
|
AuthenticationResponse,
|
||||||
|
SearchArgs,
|
||||||
|
SearchResponse,
|
||||||
|
LyricsArgs,
|
||||||
|
LyricsResponse,
|
||||||
} from '/@/renderer/api/types';
|
} from '/@/renderer/api/types';
|
||||||
import { ServerType } from '/@/renderer/types';
|
import { ServerType } from '/@/renderer/types';
|
||||||
import { DeletePlaylistResponse } from './types';
|
import { DeletePlaylistResponse, RandomSongListArgs } from './types';
|
||||||
import { ndController } from '/@/renderer/api/navidrome/navidrome-controller';
|
import { ndController } from '/@/renderer/api/navidrome/navidrome-controller';
|
||||||
import { ssController } from '/@/renderer/api/subsonic/subsonic-controller';
|
import { ssController } from '/@/renderer/api/subsonic/subsonic-controller';
|
||||||
import { jfController } from '/@/renderer/api/jellyfin/jellyfin-controller';
|
import { jfController } from '/@/renderer/api/jellyfin/jellyfin-controller';
|
||||||
@@ -74,16 +78,19 @@ export type ControllerEndpoint = Partial<{
|
|||||||
getFolderList: () => void;
|
getFolderList: () => void;
|
||||||
getFolderSongs: () => void;
|
getFolderSongs: () => void;
|
||||||
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
|
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
|
||||||
|
getLyrics: (args: LyricsArgs) => Promise<LyricsResponse>;
|
||||||
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
|
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
|
||||||
getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>;
|
getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>;
|
||||||
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
|
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
|
||||||
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
|
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
|
||||||
|
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
|
||||||
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
|
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
|
||||||
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
|
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
|
||||||
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
|
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
|
||||||
getUserList: (args: UserListArgs) => Promise<UserListResponse>;
|
getUserList: (args: UserListArgs) => Promise<UserListResponse>;
|
||||||
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
|
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
|
||||||
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
|
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
|
||||||
|
search: (args: SearchArgs) => Promise<SearchResponse>;
|
||||||
setRating: (args: SetRatingArgs) => Promise<RatingResponse>;
|
setRating: (args: SetRatingArgs) => Promise<RatingResponse>;
|
||||||
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
|
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
|
||||||
}>;
|
}>;
|
||||||
@@ -115,16 +122,19 @@ const endpoints: ApiController = {
|
|||||||
getFolderList: undefined,
|
getFolderList: undefined,
|
||||||
getFolderSongs: undefined,
|
getFolderSongs: undefined,
|
||||||
getGenreList: jfController.getGenreList,
|
getGenreList: jfController.getGenreList,
|
||||||
|
getLyrics: jfController.getLyrics,
|
||||||
getMusicFolderList: jfController.getMusicFolderList,
|
getMusicFolderList: jfController.getMusicFolderList,
|
||||||
getPlaylistDetail: jfController.getPlaylistDetail,
|
getPlaylistDetail: jfController.getPlaylistDetail,
|
||||||
getPlaylistList: jfController.getPlaylistList,
|
getPlaylistList: jfController.getPlaylistList,
|
||||||
getPlaylistSongList: jfController.getPlaylistSongList,
|
getPlaylistSongList: jfController.getPlaylistSongList,
|
||||||
|
getRandomSongList: jfController.getRandomSongList,
|
||||||
getSongDetail: undefined,
|
getSongDetail: undefined,
|
||||||
getSongList: jfController.getSongList,
|
getSongList: jfController.getSongList,
|
||||||
getTopSongs: jfController.getTopSongList,
|
getTopSongs: jfController.getTopSongList,
|
||||||
getUserList: undefined,
|
getUserList: undefined,
|
||||||
removeFromPlaylist: jfController.removeFromPlaylist,
|
removeFromPlaylist: jfController.removeFromPlaylist,
|
||||||
scrobble: jfController.scrobble,
|
scrobble: jfController.scrobble,
|
||||||
|
search: jfController.search,
|
||||||
setRating: undefined,
|
setRating: undefined,
|
||||||
updatePlaylist: jfController.updatePlaylist,
|
updatePlaylist: jfController.updatePlaylist,
|
||||||
},
|
},
|
||||||
@@ -148,16 +158,19 @@ const endpoints: ApiController = {
|
|||||||
getFolderList: undefined,
|
getFolderList: undefined,
|
||||||
getFolderSongs: undefined,
|
getFolderSongs: undefined,
|
||||||
getGenreList: ndController.getGenreList,
|
getGenreList: ndController.getGenreList,
|
||||||
|
getLyrics: undefined,
|
||||||
getMusicFolderList: ssController.getMusicFolderList,
|
getMusicFolderList: ssController.getMusicFolderList,
|
||||||
getPlaylistDetail: ndController.getPlaylistDetail,
|
getPlaylistDetail: ndController.getPlaylistDetail,
|
||||||
getPlaylistList: ndController.getPlaylistList,
|
getPlaylistList: ndController.getPlaylistList,
|
||||||
getPlaylistSongList: ndController.getPlaylistSongList,
|
getPlaylistSongList: ndController.getPlaylistSongList,
|
||||||
|
getRandomSongList: ssController.getRandomSongList,
|
||||||
getSongDetail: ndController.getSongDetail,
|
getSongDetail: ndController.getSongDetail,
|
||||||
getSongList: ndController.getSongList,
|
getSongList: ndController.getSongList,
|
||||||
getTopSongs: ssController.getTopSongList,
|
getTopSongs: ssController.getTopSongList,
|
||||||
getUserList: ndController.getUserList,
|
getUserList: ndController.getUserList,
|
||||||
removeFromPlaylist: ndController.removeFromPlaylist,
|
removeFromPlaylist: ndController.removeFromPlaylist,
|
||||||
scrobble: ssController.scrobble,
|
scrobble: ssController.scrobble,
|
||||||
|
search: ssController.search3,
|
||||||
setRating: ssController.setRating,
|
setRating: ssController.setRating,
|
||||||
updatePlaylist: ndController.updatePlaylist,
|
updatePlaylist: ndController.updatePlaylist,
|
||||||
},
|
},
|
||||||
@@ -180,6 +193,7 @@ const endpoints: ApiController = {
|
|||||||
getFolderList: undefined,
|
getFolderList: undefined,
|
||||||
getFolderSongs: undefined,
|
getFolderSongs: undefined,
|
||||||
getGenreList: undefined,
|
getGenreList: undefined,
|
||||||
|
getLyrics: undefined,
|
||||||
getMusicFolderList: ssController.getMusicFolderList,
|
getMusicFolderList: ssController.getMusicFolderList,
|
||||||
getPlaylistDetail: undefined,
|
getPlaylistDetail: undefined,
|
||||||
getPlaylistList: undefined,
|
getPlaylistList: undefined,
|
||||||
@@ -188,6 +202,7 @@ const endpoints: ApiController = {
|
|||||||
getTopSongs: ssController.getTopSongList,
|
getTopSongs: ssController.getTopSongList,
|
||||||
getUserList: undefined,
|
getUserList: undefined,
|
||||||
scrobble: ssController.scrobble,
|
scrobble: ssController.scrobble,
|
||||||
|
search: ssController.search3,
|
||||||
setRating: undefined,
|
setRating: undefined,
|
||||||
updatePlaylist: undefined,
|
updatePlaylist: undefined,
|
||||||
},
|
},
|
||||||
@@ -198,17 +213,18 @@ const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) =>
|
|||||||
|
|
||||||
if (!serverType) {
|
if (!serverType) {
|
||||||
toast.error({ message: 'No server selected', title: 'Unable to route request' });
|
toast.error({ message: 'No server selected', title: 'Unable to route request' });
|
||||||
return () => undefined;
|
throw new Error(`No server selected`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const controllerFn = endpoints[serverType][endpoint];
|
const controllerFn = endpoints?.[serverType]?.[endpoint];
|
||||||
|
|
||||||
if (typeof controllerFn !== 'function') {
|
if (typeof controllerFn !== 'function') {
|
||||||
toast.error({
|
toast.error({
|
||||||
message: `Endpoint ${endpoint} is not implemented for ${serverType}`,
|
message: `Endpoint ${endpoint} is not implemented for ${serverType}`,
|
||||||
title: 'Unable to route request',
|
title: 'Unable to route request',
|
||||||
});
|
});
|
||||||
return () => undefined;
|
|
||||||
|
throw new Error(`Endpoint ${endpoint} is not implemented for ${serverType}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return endpoints[serverType][endpoint];
|
return endpoints[serverType][endpoint];
|
||||||
@@ -249,6 +265,15 @@ const getSongList = async (args: SongListArgs) => {
|
|||||||
)?.(args);
|
)?.(args);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getSongDetail = async (args: SongDetailArgs) => {
|
||||||
|
return (
|
||||||
|
apiController(
|
||||||
|
'getSongDetail',
|
||||||
|
args.apiClientProps.server?.type,
|
||||||
|
) as ControllerEndpoint['getSongDetail']
|
||||||
|
)?.(args);
|
||||||
|
};
|
||||||
|
|
||||||
const getMusicFolderList = async (args: MusicFolderListArgs) => {
|
const getMusicFolderList = async (args: MusicFolderListArgs) => {
|
||||||
return (
|
return (
|
||||||
apiController(
|
apiController(
|
||||||
@@ -395,7 +420,10 @@ const deleteFavorite = async (args: FavoriteArgs) => {
|
|||||||
|
|
||||||
const updateRating = async (args: SetRatingArgs) => {
|
const updateRating = async (args: SetRatingArgs) => {
|
||||||
return (
|
return (
|
||||||
apiController('setRating', args.apiClientProps.server?.type) as ControllerEndpoint['setRating']
|
apiController(
|
||||||
|
'setRating',
|
||||||
|
args.apiClientProps.server?.type,
|
||||||
|
) as ControllerEndpoint['setRating']
|
||||||
)?.(args);
|
)?.(args);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -410,7 +438,34 @@ const getTopSongList = async (args: TopSongListArgs) => {
|
|||||||
|
|
||||||
const scrobble = async (args: ScrobbleArgs) => {
|
const scrobble = async (args: ScrobbleArgs) => {
|
||||||
return (
|
return (
|
||||||
apiController('scrobble', args.apiClientProps.server?.type) as ControllerEndpoint['scrobble']
|
apiController(
|
||||||
|
'scrobble',
|
||||||
|
args.apiClientProps.server?.type,
|
||||||
|
) as ControllerEndpoint['scrobble']
|
||||||
|
)?.(args);
|
||||||
|
};
|
||||||
|
|
||||||
|
const search = async (args: SearchArgs) => {
|
||||||
|
return (
|
||||||
|
apiController('search', args.apiClientProps.server?.type) as ControllerEndpoint['search']
|
||||||
|
)?.(args);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRandomSongList = async (args: RandomSongListArgs) => {
|
||||||
|
return (
|
||||||
|
apiController(
|
||||||
|
'getRandomSongList',
|
||||||
|
args.apiClientProps.server?.type,
|
||||||
|
) as ControllerEndpoint['getRandomSongList']
|
||||||
|
)?.(args);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLyrics = async (args: LyricsArgs) => {
|
||||||
|
return (
|
||||||
|
apiController(
|
||||||
|
'getLyrics',
|
||||||
|
args.apiClientProps.server?.type,
|
||||||
|
) as ControllerEndpoint['getLyrics']
|
||||||
)?.(args);
|
)?.(args);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -427,15 +482,19 @@ export const controller = {
|
|||||||
getAlbumList,
|
getAlbumList,
|
||||||
getArtistList,
|
getArtistList,
|
||||||
getGenreList,
|
getGenreList,
|
||||||
|
getLyrics,
|
||||||
getMusicFolderList,
|
getMusicFolderList,
|
||||||
getPlaylistDetail,
|
getPlaylistDetail,
|
||||||
getPlaylistList,
|
getPlaylistList,
|
||||||
getPlaylistSongList,
|
getPlaylistSongList,
|
||||||
|
getRandomSongList,
|
||||||
|
getSongDetail,
|
||||||
getSongList,
|
getSongList,
|
||||||
getTopSongList,
|
getTopSongList,
|
||||||
getUserList,
|
getUserList,
|
||||||
removeFromPlaylist,
|
removeFromPlaylist,
|
||||||
scrobble,
|
scrobble,
|
||||||
|
search,
|
||||||
updatePlaylist,
|
updatePlaylist,
|
||||||
updateRating,
|
updateRating,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export interface JFGenreListResponse extends JFBasePaginatedResponse {
|
|||||||
|
|
||||||
export type JFGenreList = JFGenreListResponse;
|
export type JFGenreList = JFGenreListResponse;
|
||||||
|
|
||||||
|
export enum JFGenreListSort {
|
||||||
|
NAME = 'SortName',
|
||||||
|
}
|
||||||
|
|
||||||
export type JFAlbumArtistDetailResponse = JFAlbumArtist;
|
export type JFAlbumArtistDetailResponse = JFAlbumArtist;
|
||||||
|
|
||||||
export type JFAlbumArtistDetail = JFAlbumArtistDetailResponse;
|
export type JFAlbumArtistDetail = JFAlbumArtistDetailResponse;
|
||||||
|
|||||||
@@ -3,24 +3,29 @@ import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
|
|||||||
import { initClient, initContract } from '@ts-rest/core';
|
import { initClient, initContract } from '@ts-rest/core';
|
||||||
import axios, { AxiosError, AxiosResponse, isAxiosError, Method } from 'axios';
|
import axios, { AxiosError, AxiosResponse, isAxiosError, Method } from 'axios';
|
||||||
import qs from 'qs';
|
import qs from 'qs';
|
||||||
import { toast } from '/@/renderer/components';
|
|
||||||
import { ServerListItem } from '/@/renderer/types';
|
import { ServerListItem } from '/@/renderer/types';
|
||||||
import omitBy from 'lodash/omitBy';
|
import omitBy from 'lodash/omitBy';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { authenticationFailure } from '/@/renderer/api/utils';
|
||||||
|
|
||||||
const c = initContract();
|
const c = initContract();
|
||||||
|
|
||||||
export const contract = c.router({
|
export const contract = c.router({
|
||||||
addToPlaylist: {
|
addToPlaylist: {
|
||||||
body: jfType._parameters.addToPlaylist,
|
body: z.null(),
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: 'playlists/:id/items',
|
path: 'playlists/:id/items',
|
||||||
|
query: jfType._parameters.addToPlaylist,
|
||||||
responses: {
|
responses: {
|
||||||
200: jfType._response.addToPlaylist,
|
204: jfType._response.addToPlaylist,
|
||||||
400: jfType._response.error,
|
400: jfType._response.error,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
authenticate: {
|
authenticate: {
|
||||||
body: jfType._parameters.authenticate,
|
body: jfType._parameters.authenticate,
|
||||||
|
headers: z.object({
|
||||||
|
'X-Emby-Authorization': z.string(),
|
||||||
|
}),
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: 'users/authenticatebyname',
|
path: 'users/authenticatebyname',
|
||||||
responses: {
|
responses: {
|
||||||
@@ -103,6 +108,7 @@ export const contract = c.router({
|
|||||||
getGenreList: {
|
getGenreList: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: 'genres',
|
path: 'genres',
|
||||||
|
query: jfType._parameters.genreList,
|
||||||
responses: {
|
responses: {
|
||||||
200: jfType._response.genreList,
|
200: jfType._response.genreList,
|
||||||
400: jfType._response.error,
|
400: jfType._response.error,
|
||||||
@@ -169,6 +175,14 @@ export const contract = c.router({
|
|||||||
400: jfType._response.error,
|
400: jfType._response.error,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
getSongLyrics: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'users/:userId/Items/:id/Lyrics',
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.lyrics,
|
||||||
|
404: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
getTopSongsList: {
|
getTopSongsList: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: 'users/:userId/items',
|
path: 'users/:userId/items',
|
||||||
@@ -190,7 +204,7 @@ export const contract = c.router({
|
|||||||
removeFromPlaylist: {
|
removeFromPlaylist: {
|
||||||
body: null,
|
body: null,
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
path: 'items/:id',
|
path: 'playlists/:id/items',
|
||||||
query: jfType._parameters.removeFromPlaylist,
|
query: jfType._parameters.removeFromPlaylist,
|
||||||
responses: {
|
responses: {
|
||||||
200: jfType._response.removeFromPlaylist,
|
200: jfType._response.removeFromPlaylist,
|
||||||
@@ -224,6 +238,15 @@ export const contract = c.router({
|
|||||||
400: jfType._response.error,
|
400: jfType._response.error,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
search: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'users/:userId/items',
|
||||||
|
query: jfType._parameters.search,
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.search,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
updatePlaylist: {
|
updatePlaylist: {
|
||||||
body: jfType._parameters.updatePlaylist,
|
body: jfType._parameters.updatePlaylist,
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -247,19 +270,15 @@ axiosClient.interceptors.response.use(
|
|||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error.response && error.response.status === 401) {
|
if (error.response && error.response.status === 401) {
|
||||||
toast.error({
|
|
||||||
message: 'Your session has expired.',
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentServer = useAuthStore.getState().currentServer;
|
const currentServer = useAuthStore.getState().currentServer;
|
||||||
|
|
||||||
if (currentServer) {
|
if (currentServer) {
|
||||||
const serverId = currentServer.id;
|
useAuthStore
|
||||||
const token = currentServer.credential;
|
.getState()
|
||||||
console.log(`token is expired: ${token}`);
|
.actions.updateServer(currentServer.id, { credential: undefined });
|
||||||
useAuthStore.getState().actions.setCurrentServer(null);
|
|
||||||
useAuthStore.getState().actions.updateServer(serverId, { credential: undefined });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
authenticationFailure(currentServer);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
@@ -313,6 +332,7 @@ export const jfApiClient = (args: {
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
body: result.data,
|
body: result.data,
|
||||||
|
headers: result.headers as any,
|
||||||
status: result.status,
|
status: result.status,
|
||||||
};
|
};
|
||||||
} catch (e: Error | AxiosError | any) {
|
} catch (e: Error | AxiosError | any) {
|
||||||
@@ -320,7 +340,8 @@ export const jfApiClient = (args: {
|
|||||||
const error = e as AxiosError;
|
const error = e as AxiosError;
|
||||||
const response = error.response as AxiosResponse;
|
const response = error.response as AxiosResponse;
|
||||||
return {
|
return {
|
||||||
body: response.data,
|
body: response?.data,
|
||||||
|
headers: response?.headers as any,
|
||||||
status: response.status,
|
status: response.status,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,16 +40,51 @@ import {
|
|||||||
RemoveFromPlaylistResponse,
|
RemoveFromPlaylistResponse,
|
||||||
PlaylistDetailResponse,
|
PlaylistDetailResponse,
|
||||||
PlaylistListResponse,
|
PlaylistListResponse,
|
||||||
|
SearchArgs,
|
||||||
|
SearchResponse,
|
||||||
|
RandomSongListResponse,
|
||||||
|
RandomSongListArgs,
|
||||||
|
LyricsArgs,
|
||||||
|
LyricsResponse,
|
||||||
|
genreListSortMap,
|
||||||
} from '/@/renderer/api/types';
|
} from '/@/renderer/api/types';
|
||||||
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
|
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
|
||||||
import { jfNormalize } from './jellyfin-normalize';
|
import { jfNormalize } from './jellyfin-normalize';
|
||||||
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
|
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
|
||||||
import packageJson from '../../../../package.json';
|
import packageJson from '../../../../package.json';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types';
|
||||||
|
import isElectron from 'is-electron';
|
||||||
|
|
||||||
const formatCommaDelimitedString = (value: string[]) => {
|
const formatCommaDelimitedString = (value: string[]) => {
|
||||||
return value.join(',');
|
return value.join(',');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getHostname(): string {
|
||||||
|
if (isElectron()) {
|
||||||
|
return 'Desktop Client';
|
||||||
|
}
|
||||||
|
const agent = navigator.userAgent;
|
||||||
|
switch (true) {
|
||||||
|
case agent.toLowerCase().indexOf('edge') > -1:
|
||||||
|
return 'Microsoft Edge';
|
||||||
|
case agent.toLowerCase().indexOf('edg/') > -1:
|
||||||
|
return 'Edge Chromium'; // Match also / to avoid matching for the older Edge
|
||||||
|
case agent.toLowerCase().indexOf('opr') > -1:
|
||||||
|
return 'Opera';
|
||||||
|
case agent.toLowerCase().indexOf('chrome') > -1:
|
||||||
|
return 'Chrome';
|
||||||
|
case agent.toLowerCase().indexOf('trident') > -1:
|
||||||
|
return 'Internet Explorer';
|
||||||
|
case agent.toLowerCase().indexOf('firefox') > -1:
|
||||||
|
return 'Firefox';
|
||||||
|
case agent.toLowerCase().indexOf('safari') > -1:
|
||||||
|
return 'Safari';
|
||||||
|
default:
|
||||||
|
return 'PC';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const authenticate = async (
|
const authenticate = async (
|
||||||
url: string,
|
url: string,
|
||||||
body: {
|
body: {
|
||||||
@@ -65,7 +100,9 @@ const authenticate = async (
|
|||||||
Username: body.username,
|
Username: body.username,
|
||||||
},
|
},
|
||||||
headers: {
|
headers: {
|
||||||
'X-Emby-Authorization': `MediaBrowser Client="Feishin", Device="PC", DeviceId="Feishin", Version="${packageJson.version}"`,
|
'x-emby-authorization': `MediaBrowser Client="Feishin", Device="${getHostname()}", DeviceId="Feishin", Version="${
|
||||||
|
packageJson.version
|
||||||
|
}"`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,18 +145,33 @@ const getMusicFolderList = async (args: MusicFolderListArgs): Promise<MusicFolde
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getGenreList = async (args: GenreListArgs): Promise<GenreListResponse> => {
|
const getGenreList = async (args: GenreListArgs): Promise<GenreListResponse> => {
|
||||||
const { apiClientProps } = args;
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
const res = await jfApiClient(apiClientProps).getGenreList();
|
if (!apiClientProps.server?.userId) {
|
||||||
|
throw new Error('No userId found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await jfApiClient(apiClientProps).getGenreList({
|
||||||
|
query: {
|
||||||
|
Fields: 'ItemCounts',
|
||||||
|
ParentId: query?.musicFolderId,
|
||||||
|
Recursive: true,
|
||||||
|
SearchTerm: query?.searchTerm,
|
||||||
|
SortBy: genreListSortMap.jellyfin[query.sortBy] || 'SortName',
|
||||||
|
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||||
|
StartIndex: query.startIndex,
|
||||||
|
UserId: apiClientProps.server?.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (res.status !== 200) {
|
if (res.status !== 200) {
|
||||||
throw new Error('Failed to get genre list');
|
throw new Error('Failed to get genre list');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: res.body.Items.map(jfNormalize.genre),
|
items: res.body.Items.map((item) => jfNormalize.genre(item, apiClientProps.server)),
|
||||||
startIndex: 0,
|
startIndex: query.startIndex || 0,
|
||||||
totalRecordCount: res.body?.Items?.length || 0,
|
totalRecordCount: res.body?.TotalRecordCount || 0,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -240,7 +292,7 @@ const getAlbumDetail = async (args: AlbumDetailArgs): Promise<AlbumDetailRespons
|
|||||||
Fields: 'Genres, DateCreated, MediaSources, ParentId',
|
Fields: 'Genres, DateCreated, MediaSources, ParentId',
|
||||||
IncludeItemTypes: 'Audio',
|
IncludeItemTypes: 'Audio',
|
||||||
ParentId: query.id,
|
ParentId: query.id,
|
||||||
SortBy: 'Album,SortName',
|
SortBy: 'ParentIndexNumber,IndexNumber,SortName',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -276,6 +328,9 @@ const getAlbumList = async (args: AlbumListArgs): Promise<AlbumListResponse> =>
|
|||||||
userId: apiClientProps.server?.userId,
|
userId: apiClientProps.server?.userId,
|
||||||
},
|
},
|
||||||
query: {
|
query: {
|
||||||
|
AlbumArtistIds: query.artistIds
|
||||||
|
? formatCommaDelimitedString(query.artistIds)
|
||||||
|
: undefined,
|
||||||
IncludeItemTypes: 'MusicAlbum',
|
IncludeItemTypes: 'MusicAlbum',
|
||||||
Limit: query.limit,
|
Limit: query.limit,
|
||||||
ParentId: query.musicFolderId,
|
ParentId: query.musicFolderId,
|
||||||
@@ -354,7 +409,9 @@ const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
|
|||||||
|
|
||||||
const yearsFilter = yearsGroup.length ? formatCommaDelimitedString(yearsGroup) : undefined;
|
const yearsFilter = yearsGroup.length ? formatCommaDelimitedString(yearsGroup) : undefined;
|
||||||
const albumIdsFilter = query.albumIds ? formatCommaDelimitedString(query.albumIds) : undefined;
|
const albumIdsFilter = query.albumIds ? formatCommaDelimitedString(query.albumIds) : undefined;
|
||||||
const artistIdsFilter = query.artistIds ? formatCommaDelimitedString(query.artistIds) : undefined;
|
const artistIdsFilter = query.artistIds
|
||||||
|
? formatCommaDelimitedString(query.artistIds)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const res = await jfApiClient(apiClientProps).getSongList({
|
const res = await jfApiClient(apiClientProps).getSongList({
|
||||||
params: {
|
params: {
|
||||||
@@ -382,7 +439,9 @@ const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
|
items: res.body.Items.map((item) =>
|
||||||
|
jfNormalize.song(item, apiClientProps.server, '', query.imageSize),
|
||||||
|
),
|
||||||
startIndex: query.startIndex,
|
startIndex: query.startIndex,
|
||||||
totalRecordCount: res.body.TotalRecordCount,
|
totalRecordCount: res.body.TotalRecordCount,
|
||||||
};
|
};
|
||||||
@@ -396,16 +455,17 @@ const addToPlaylist = async (args: AddToPlaylistArgs): Promise<AddToPlaylistResp
|
|||||||
}
|
}
|
||||||
|
|
||||||
const res = await jfApiClient(apiClientProps).addToPlaylist({
|
const res = await jfApiClient(apiClientProps).addToPlaylist({
|
||||||
body: {
|
body: null,
|
||||||
Ids: body.songId,
|
|
||||||
UserId: apiClientProps?.server?.userId,
|
|
||||||
},
|
|
||||||
params: {
|
params: {
|
||||||
id: query.id,
|
id: query.id,
|
||||||
},
|
},
|
||||||
|
query: {
|
||||||
|
Ids: body.songId,
|
||||||
|
UserId: apiClientProps.server?.userId,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status !== 200) {
|
if (res.status !== 204) {
|
||||||
throw new Error('Failed to add to playlist');
|
throw new Error('Failed to add to playlist');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,7 +487,7 @@ const removeFromPlaylist = async (
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status !== 200) {
|
if (res.status !== 204) {
|
||||||
throw new Error('Failed to remove from playlist');
|
throw new Error('Failed to remove from playlist');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,6 +559,20 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResp
|
|||||||
throw new Error('No userId found');
|
throw new Error('No userId found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const musicFoldersRes = await jfApiClient(apiClientProps).getMusicFolderList({
|
||||||
|
params: {
|
||||||
|
userId: apiClientProps.server?.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (musicFoldersRes.status !== 200) {
|
||||||
|
throw new Error('Failed playlist folder');
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlistFolder = musicFoldersRes.body.Items.filter(
|
||||||
|
(folder) => folder.CollectionType === jfType._enum.collection.PLAYLISTS,
|
||||||
|
)?.[0];
|
||||||
|
|
||||||
const res = await jfApiClient(apiClientProps).getPlaylistList({
|
const res = await jfApiClient(apiClientProps).getPlaylistList({
|
||||||
params: {
|
params: {
|
||||||
userId: apiClientProps.server?.userId,
|
userId: apiClientProps.server?.userId,
|
||||||
@@ -507,8 +581,8 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResp
|
|||||||
Fields: 'ChildCount, Genres, DateCreated, ParentId, Overview',
|
Fields: 'ChildCount, Genres, DateCreated, ParentId, Overview',
|
||||||
IncludeItemTypes: 'Playlist',
|
IncludeItemTypes: 'Playlist',
|
||||||
Limit: query.limit,
|
Limit: query.limit,
|
||||||
MediaTypes: 'Audio',
|
ParentId: playlistFolder?.Id,
|
||||||
Recursive: true,
|
SearchTerm: query.searchTerm,
|
||||||
SortBy: playlistListSortMap.jellyfin[query.sortBy],
|
SortBy: playlistListSortMap.jellyfin[query.sortBy],
|
||||||
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||||
StartIndex: query.startIndex,
|
StartIndex: query.startIndex,
|
||||||
@@ -703,6 +777,169 @@ const scrobble = async (args: ScrobbleArgs): Promise<ScrobbleResponse> => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const search = async (args: SearchArgs): Promise<SearchResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
if (!apiClientProps.server?.userId) {
|
||||||
|
throw new Error('No userId found');
|
||||||
|
}
|
||||||
|
|
||||||
|
let albums: z.infer<typeof jfType._response.albumList>['Items'] = [];
|
||||||
|
let albumArtists: z.infer<typeof jfType._response.albumArtistList>['Items'] = [];
|
||||||
|
let songs: z.infer<typeof jfType._response.songList>['Items'] = [];
|
||||||
|
|
||||||
|
if (query.albumLimit) {
|
||||||
|
const res = await jfApiClient(apiClientProps).getAlbumList({
|
||||||
|
params: {
|
||||||
|
userId: apiClientProps.server?.userId,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
EnableTotalRecordCount: true,
|
||||||
|
ImageTypeLimit: 1,
|
||||||
|
IncludeItemTypes: 'MusicAlbum',
|
||||||
|
Limit: query.albumLimit,
|
||||||
|
Recursive: true,
|
||||||
|
SearchTerm: query.query,
|
||||||
|
SortBy: 'SortName',
|
||||||
|
SortOrder: 'Ascending',
|
||||||
|
StartIndex: query.albumStartIndex || 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get album list');
|
||||||
|
}
|
||||||
|
|
||||||
|
albums = res.body.Items;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.albumArtistLimit) {
|
||||||
|
const res = await jfApiClient(apiClientProps).getAlbumArtistList({
|
||||||
|
query: {
|
||||||
|
EnableTotalRecordCount: true,
|
||||||
|
Fields: 'Genres, DateCreated, ExternalUrls, Overview',
|
||||||
|
ImageTypeLimit: 1,
|
||||||
|
IncludeArtists: true,
|
||||||
|
Limit: query.albumArtistLimit,
|
||||||
|
Recursive: true,
|
||||||
|
SearchTerm: query.query,
|
||||||
|
StartIndex: query.albumArtistStartIndex || 0,
|
||||||
|
UserId: apiClientProps.server?.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get album artist list');
|
||||||
|
}
|
||||||
|
|
||||||
|
albumArtists = res.body.Items;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.songLimit) {
|
||||||
|
const res = await jfApiClient(apiClientProps).getSongList({
|
||||||
|
params: {
|
||||||
|
userId: apiClientProps.server?.userId,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
EnableTotalRecordCount: true,
|
||||||
|
Fields: 'Genres, DateCreated, MediaSources, ParentId',
|
||||||
|
IncludeItemTypes: 'Audio',
|
||||||
|
Limit: query.songLimit,
|
||||||
|
Recursive: true,
|
||||||
|
SearchTerm: query.query,
|
||||||
|
SortBy: 'Album,SortName',
|
||||||
|
SortOrder: 'Ascending',
|
||||||
|
StartIndex: query.songStartIndex || 0,
|
||||||
|
UserId: apiClientProps.server?.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get song list');
|
||||||
|
}
|
||||||
|
|
||||||
|
songs = res.body.Items;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
albumArtists: albumArtists.map((item) =>
|
||||||
|
jfNormalize.albumArtist(item, apiClientProps.server),
|
||||||
|
),
|
||||||
|
albums: albums.map((item) => jfNormalize.album(item, apiClientProps.server)),
|
||||||
|
songs: songs.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRandomSongList = async (args: RandomSongListArgs): Promise<RandomSongListResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
if (!apiClientProps.server?.userId) {
|
||||||
|
throw new Error('No userId found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const yearsGroup = [];
|
||||||
|
if (query.minYear && query.maxYear) {
|
||||||
|
for (let i = Number(query.minYear); i <= Number(query.maxYear); i += 1) {
|
||||||
|
yearsGroup.push(String(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const yearsFilter = yearsGroup.length ? formatCommaDelimitedString(yearsGroup) : undefined;
|
||||||
|
|
||||||
|
const res = await jfApiClient(apiClientProps).getSongList({
|
||||||
|
params: {
|
||||||
|
userId: apiClientProps.server?.userId,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
Fields: 'Genres, DateCreated, MediaSources, ParentId',
|
||||||
|
GenreIds: query.genre ? query.genre : undefined,
|
||||||
|
IncludeItemTypes: 'Audio',
|
||||||
|
Limit: query.limit,
|
||||||
|
ParentId: query.musicFolderId,
|
||||||
|
Recursive: true,
|
||||||
|
SortBy: JFSongListSort.RANDOM,
|
||||||
|
SortOrder: JFSortOrder.ASC,
|
||||||
|
StartIndex: 0,
|
||||||
|
Years: yearsFilter,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get random songs');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
|
||||||
|
startIndex: 0,
|
||||||
|
totalRecordCount: res.body.Items.length || 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLyrics = async (args: LyricsArgs): Promise<LyricsResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
if (!apiClientProps.server?.userId) {
|
||||||
|
throw new Error('No userId found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await jfApiClient(apiClientProps).getSongLyrics({
|
||||||
|
params: {
|
||||||
|
id: query.songId,
|
||||||
|
userId: apiClientProps.server?.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get lyrics');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.body.Lyrics.length > 0 && res.body.Lyrics[0].Start === undefined) {
|
||||||
|
return res.body.Lyrics[0].Text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.body.Lyrics.map((lyric) => [lyric.Start! / 1e4, lyric.Text]);
|
||||||
|
};
|
||||||
|
|
||||||
export const jfController = {
|
export const jfController = {
|
||||||
addToPlaylist,
|
addToPlaylist,
|
||||||
authenticate,
|
authenticate,
|
||||||
@@ -716,13 +953,16 @@ export const jfController = {
|
|||||||
getAlbumList,
|
getAlbumList,
|
||||||
getArtistList,
|
getArtistList,
|
||||||
getGenreList,
|
getGenreList,
|
||||||
|
getLyrics,
|
||||||
getMusicFolderList,
|
getMusicFolderList,
|
||||||
getPlaylistDetail,
|
getPlaylistDetail,
|
||||||
getPlaylistList,
|
getPlaylistList,
|
||||||
getPlaylistSongList,
|
getPlaylistSongList,
|
||||||
|
getRandomSongList,
|
||||||
getSongList,
|
getSongList,
|
||||||
getTopSongList,
|
getTopSongList,
|
||||||
removeFromPlaylist,
|
removeFromPlaylist,
|
||||||
scrobble,
|
scrobble,
|
||||||
|
search,
|
||||||
updatePlaylist,
|
updatePlaylist,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -80,10 +80,6 @@ const getSongCoverArtUrl = (args: {
|
|||||||
}) => {
|
}) => {
|
||||||
const size = args.size ? args.size : 100;
|
const size = args.size ? args.size : 100;
|
||||||
|
|
||||||
if (!args.item.ImageTags?.Primary) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.item.ImageTags.Primary) {
|
if (args.item.ImageTags.Primary) {
|
||||||
return (
|
return (
|
||||||
`${args.baseUrl}/Items` +
|
`${args.baseUrl}/Items` +
|
||||||
@@ -94,10 +90,7 @@ const getSongCoverArtUrl = (args: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!args.item?.AlbumPrimaryImageTag) {
|
if (args.item?.AlbumPrimaryImageTag) {
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to album art if no image embedded
|
// Fall back to album art if no image embedded
|
||||||
return (
|
return (
|
||||||
`${args.baseUrl}/Items` +
|
`${args.baseUrl}/Items` +
|
||||||
@@ -106,6 +99,9 @@ const getSongCoverArtUrl = (args: {
|
|||||||
`?width=${size}&height=${size}` +
|
`?width=${size}&height=${size}` +
|
||||||
'&quality=96'
|
'&quality=96'
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPlaylistCoverArtUrl = (args: { baseUrl: string; item: JFPlaylist; size: number }) => {
|
const getPlaylistCoverArtUrl = (args: { baseUrl: string; item: JFPlaylist; size: number }) => {
|
||||||
@@ -138,9 +134,15 @@ const normalizeSong = (
|
|||||||
name: entry.Name,
|
name: entry.Name,
|
||||||
})),
|
})),
|
||||||
albumId: item.AlbumId,
|
albumId: item.AlbumId,
|
||||||
artistName: item.ArtistItems[0]?.Name,
|
artistName: item?.ArtistItems?.[0]?.Name,
|
||||||
artists: item.ArtistItems.map((entry) => ({ id: entry.Id, imageUrl: null, name: entry.Name })),
|
artists: item?.ArtistItems?.map((entry) => ({
|
||||||
bitRate: item.MediaSources && Number(Math.trunc(item.MediaSources[0]?.Bitrate / 1000)),
|
id: entry.Id,
|
||||||
|
imageUrl: null,
|
||||||
|
name: entry.Name,
|
||||||
|
})),
|
||||||
|
bitRate:
|
||||||
|
item.MediaSources?.[0].Bitrate &&
|
||||||
|
Number(Math.trunc(item.MediaSources[0].Bitrate / 1000)),
|
||||||
bpm: null,
|
bpm: null,
|
||||||
channels: null,
|
channels: null,
|
||||||
comment: null,
|
comment: null,
|
||||||
@@ -148,15 +150,28 @@ const normalizeSong = (
|
|||||||
container: (item.MediaSources && item.MediaSources[0]?.Container) || null,
|
container: (item.MediaSources && item.MediaSources[0]?.Container) || null,
|
||||||
createdAt: item.DateCreated,
|
createdAt: item.DateCreated,
|
||||||
discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1,
|
discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1,
|
||||||
duration: item.RunTimeTicks / 10000000,
|
discSubtitle: null,
|
||||||
genres: item.GenreItems.map((entry: any) => ({ id: entry.Id, name: entry.Name })),
|
duration: item.RunTimeTicks / 10000,
|
||||||
|
gain: item.LUFS
|
||||||
|
? {
|
||||||
|
track: -18 - item.LUFS,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
genres: item.GenreItems?.map((entry) => ({
|
||||||
|
id: entry.Id,
|
||||||
|
imageUrl: null,
|
||||||
|
itemType: LibraryItem.GENRE,
|
||||||
|
name: entry.Name,
|
||||||
|
})),
|
||||||
id: item.Id,
|
id: item.Id,
|
||||||
imagePlaceholderUrl: null,
|
imagePlaceholderUrl: null,
|
||||||
imageUrl: getSongCoverArtUrl({ baseUrl: server?.url || '', item, size: imageSize || 100 }),
|
imageUrl: getSongCoverArtUrl({ baseUrl: server?.url || '', item, size: imageSize || 100 }),
|
||||||
itemType: LibraryItem.SONG,
|
itemType: LibraryItem.SONG,
|
||||||
lastPlayedAt: null,
|
lastPlayedAt: null,
|
||||||
|
lyrics: null,
|
||||||
name: item.Name,
|
name: item.Name,
|
||||||
path: (item.MediaSources && item.MediaSources[0]?.Path) || null,
|
path: (item.MediaSources && item.MediaSources[0]?.Path) || null,
|
||||||
|
peak: null,
|
||||||
playCount: (item.UserData && item.UserData.PlayCount) || 0,
|
playCount: (item.UserData && item.UserData.PlayCount) || 0,
|
||||||
playlistItemId: item.PlaylistItemId,
|
playlistItemId: item.PlaylistItemId,
|
||||||
// releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null,
|
// releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null,
|
||||||
@@ -166,11 +181,11 @@ const normalizeSong = (
|
|||||||
serverType: ServerType.JELLYFIN,
|
serverType: ServerType.JELLYFIN,
|
||||||
size: item.MediaSources && item.MediaSources[0]?.Size,
|
size: item.MediaSources && item.MediaSources[0]?.Size,
|
||||||
streamUrl: getStreamUrl({
|
streamUrl: getStreamUrl({
|
||||||
container: item.MediaSources[0]?.Container,
|
container: item.MediaSources?.[0]?.Container,
|
||||||
deviceId,
|
deviceId,
|
||||||
eTag: item.MediaSources[0]?.ETag,
|
eTag: item.MediaSources?.[0]?.ETag,
|
||||||
id: item.Id,
|
id: item.Id,
|
||||||
mediaSourceId: item.MediaSources[0]?.Id,
|
mediaSourceId: item.MediaSources?.[0]?.Id,
|
||||||
server,
|
server,
|
||||||
}),
|
}),
|
||||||
trackNumber: item.IndexNumber,
|
trackNumber: item.IndexNumber,
|
||||||
@@ -193,11 +208,20 @@ const normalizeAlbum = (
|
|||||||
imageUrl: null,
|
imageUrl: null,
|
||||||
name: entry.Name,
|
name: entry.Name,
|
||||||
})) || [],
|
})) || [],
|
||||||
artists: item.ArtistItems?.map((entry) => ({ id: entry.Id, imageUrl: null, name: entry.Name })),
|
artists: item.ArtistItems?.map((entry) => ({
|
||||||
|
id: entry.Id,
|
||||||
|
imageUrl: null,
|
||||||
|
name: entry.Name,
|
||||||
|
})),
|
||||||
backdropImageUrl: null,
|
backdropImageUrl: null,
|
||||||
createdAt: item.DateCreated,
|
createdAt: item.DateCreated,
|
||||||
duration: item.RunTimeTicks / 10000,
|
duration: item.RunTimeTicks / 10000,
|
||||||
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
genres: item.GenreItems?.map((entry) => ({
|
||||||
|
id: entry.Id,
|
||||||
|
imageUrl: null,
|
||||||
|
itemType: LibraryItem.GENRE,
|
||||||
|
name: entry.Name,
|
||||||
|
})),
|
||||||
id: item.Id,
|
id: item.Id,
|
||||||
imagePlaceholderUrl: null,
|
imagePlaceholderUrl: null,
|
||||||
imageUrl: getAlbumCoverArtUrl({
|
imageUrl: getAlbumCoverArtUrl({
|
||||||
@@ -249,7 +273,12 @@ const normalizeAlbumArtist = (
|
|||||||
backgroundImageUrl: null,
|
backgroundImageUrl: null,
|
||||||
biography: item.Overview || null,
|
biography: item.Overview || null,
|
||||||
duration: item.RunTimeTicks / 10000,
|
duration: item.RunTimeTicks / 10000,
|
||||||
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
genres: item.GenreItems?.map((entry) => ({
|
||||||
|
id: entry.Id,
|
||||||
|
imageUrl: null,
|
||||||
|
itemType: LibraryItem.GENRE,
|
||||||
|
name: entry.Name,
|
||||||
|
})),
|
||||||
id: item.Id,
|
id: item.Id,
|
||||||
imageUrl: getAlbumArtistCoverArtUrl({
|
imageUrl: getAlbumArtistCoverArtUrl({
|
||||||
baseUrl: server?.url || '',
|
baseUrl: server?.url || '',
|
||||||
@@ -285,7 +314,12 @@ const normalizePlaylist = (
|
|||||||
return {
|
return {
|
||||||
description: item.Overview || null,
|
description: item.Overview || null,
|
||||||
duration: item.RunTimeTicks / 10000,
|
duration: item.RunTimeTicks / 10000,
|
||||||
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
genres: item.GenreItems?.map((entry) => ({
|
||||||
|
id: entry.Id,
|
||||||
|
imageUrl: null,
|
||||||
|
itemType: LibraryItem.GENRE,
|
||||||
|
name: entry.Name,
|
||||||
|
})),
|
||||||
id: item.Id,
|
id: item.Id,
|
||||||
imagePlaceholderUrl,
|
imagePlaceholderUrl,
|
||||||
imageUrl: imageUrl || null,
|
imageUrl: imageUrl || null,
|
||||||
@@ -330,10 +364,32 @@ const normalizeMusicFolder = (item: JFMusicFolder): MusicFolder => {
|
|||||||
// };
|
// };
|
||||||
// };
|
// };
|
||||||
|
|
||||||
const normalizeGenre = (item: JFGenre): Genre => {
|
const getGenreCoverArtUrl = (args: {
|
||||||
|
baseUrl: string;
|
||||||
|
item: z.infer<typeof jfType._response.genre>;
|
||||||
|
size: number;
|
||||||
|
}) => {
|
||||||
|
const size = args.size ? args.size : 300;
|
||||||
|
|
||||||
|
if (!args.item.ImageTags?.Primary) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
`${args.baseUrl}/Items` +
|
||||||
|
`/${args.item.Id}` +
|
||||||
|
'/Images/Primary' +
|
||||||
|
`?width=${size}&height=${size}` +
|
||||||
|
'&quality=96'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeGenre = (item: JFGenre, server: ServerListItem | null): Genre => {
|
||||||
return {
|
return {
|
||||||
albumCount: undefined,
|
albumCount: undefined,
|
||||||
id: item.Id,
|
id: item.Id,
|
||||||
|
imageUrl: getGenreCoverArtUrl({ baseUrl: server?.url || '', item, size: 200 }),
|
||||||
|
itemType: LibraryItem.GENRE,
|
||||||
name: item.Name,
|
name: item.Name,
|
||||||
songCount: undefined,
|
songCount: undefined,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,28 +39,39 @@ const error = z.object({
|
|||||||
const baseParameters = z.object({
|
const baseParameters = z.object({
|
||||||
AlbumArtistIds: z.string().optional(),
|
AlbumArtistIds: z.string().optional(),
|
||||||
ArtistIds: z.string().optional(),
|
ArtistIds: z.string().optional(),
|
||||||
|
ContributingArtistIds: z.string().optional(),
|
||||||
EnableImageTypes: z.string().optional(),
|
EnableImageTypes: z.string().optional(),
|
||||||
EnableTotalRecordCount: z.boolean().optional(),
|
EnableTotalRecordCount: z.boolean().optional(),
|
||||||
EnableUserData: z.boolean().optional(),
|
EnableUserData: z.boolean().optional(),
|
||||||
|
EnableUserDataTypes: z.boolean().optional(),
|
||||||
|
ExcludeArtistIds: z.string().optional(),
|
||||||
|
ExcludeItemIds: z.string().optional(),
|
||||||
ExcludeItemTypes: z.string().optional(),
|
ExcludeItemTypes: z.string().optional(),
|
||||||
Fields: z.string().optional(),
|
Fields: z.string().optional(),
|
||||||
ImageTypeLimit: z.number().optional(),
|
ImageTypeLimit: z.number().optional(),
|
||||||
|
IncludeArtists: z.boolean().optional(),
|
||||||
|
IncludeGenres: z.boolean().optional(),
|
||||||
IncludeItemTypes: z.string().optional(),
|
IncludeItemTypes: z.string().optional(),
|
||||||
|
IncludeMedia: z.boolean().optional(),
|
||||||
|
IncludePeople: z.boolean().optional(),
|
||||||
|
IncludeStudios: z.boolean().optional(),
|
||||||
IsFavorite: z.boolean().optional(),
|
IsFavorite: z.boolean().optional(),
|
||||||
Limit: z.number().optional(),
|
Limit: z.number().optional(),
|
||||||
MediaTypes: z.string().optional(),
|
MediaTypes: z.string().optional(),
|
||||||
|
NameStartsWith: z.string().optional(),
|
||||||
ParentId: z.string().optional(),
|
ParentId: z.string().optional(),
|
||||||
Recursive: z.boolean().optional(),
|
Recursive: z.boolean().optional(),
|
||||||
SearchTerm: z.string().optional(),
|
SearchTerm: z.string().optional(),
|
||||||
SortBy: z.string().optional(),
|
SortBy: z.string().optional(),
|
||||||
SortOrder: z.enum(sortOrderValues).optional(),
|
SortOrder: z.enum(sortOrderValues).optional(),
|
||||||
StartIndex: z.number().optional(),
|
StartIndex: z.number().optional(),
|
||||||
|
Tags: z.string().optional(),
|
||||||
UserId: z.string().optional(),
|
UserId: z.string().optional(),
|
||||||
|
Years: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const paginationParameters = z.object({
|
const paginationParameters = z.object({
|
||||||
Limit: z.number().optional(),
|
Limit: z.number().optional(),
|
||||||
NameStartsWith: z.string().optional(),
|
|
||||||
SortOrder: z.enum(sortOrderValues).optional(),
|
SortOrder: z.enum(sortOrderValues).optional(),
|
||||||
StartIndex: z.number().optional(),
|
StartIndex: z.number().optional(),
|
||||||
});
|
});
|
||||||
@@ -76,9 +87,9 @@ const imageTags = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const imageBlurHashes = z.object({
|
const imageBlurHashes = z.object({
|
||||||
Backdrop: z.string().optional(),
|
Backdrop: z.record(z.string(), z.string()).optional(),
|
||||||
Logo: z.string().optional(),
|
Logo: z.record(z.string(), z.string()).optional(),
|
||||||
Primary: z.string().optional(),
|
Primary: z.record(z.string(), z.string()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const userData = z.object({
|
const userData = z.object({
|
||||||
@@ -293,10 +304,21 @@ const genre = z.object({
|
|||||||
Type: z.string(),
|
Type: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const genreList = z.object({
|
const genreList = pagination.extend({
|
||||||
Items: z.array(genre),
|
Items: z.array(genre),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const genreListSort = {
|
||||||
|
NAME: 'SortName',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const genreListParameters = paginationParameters.merge(
|
||||||
|
baseParameters.extend({
|
||||||
|
SearchTerm: z.string().optional(),
|
||||||
|
SortBy: z.nativeEnum(genreListSort).optional(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const musicFolder = z.object({
|
const musicFolder = z.object({
|
||||||
BackdropImageTags: z.array(z.string()),
|
BackdropImageTags: z.array(z.string()),
|
||||||
ChannelId: z.null(),
|
ChannelId: z.null(),
|
||||||
@@ -341,7 +363,7 @@ const playlist = z.object({
|
|||||||
UserData: userData,
|
UserData: userData,
|
||||||
});
|
});
|
||||||
|
|
||||||
const jfPlaylistListSort = {
|
const playlistListSort = {
|
||||||
ALBUM_ARTIST: 'AlbumArtist,SortName',
|
ALBUM_ARTIST: 'AlbumArtist,SortName',
|
||||||
DURATION: 'Runtime',
|
DURATION: 'Runtime',
|
||||||
NAME: 'SortName',
|
NAME: 'SortName',
|
||||||
@@ -352,7 +374,7 @@ const jfPlaylistListSort = {
|
|||||||
const playlistListParameters = paginationParameters.merge(
|
const playlistListParameters = paginationParameters.merge(
|
||||||
baseParameters.extend({
|
baseParameters.extend({
|
||||||
IncludeItemTypes: z.literal('Playlist'),
|
IncludeItemTypes: z.literal('Playlist'),
|
||||||
SortBy: z.nativeEnum(jfPlaylistListSort).optional(),
|
SortBy: z.nativeEnum(playlistListSort).optional(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -384,6 +406,7 @@ const song = z.object({
|
|||||||
ImageTags: imageTags,
|
ImageTags: imageTags,
|
||||||
IndexNumber: z.number(),
|
IndexNumber: z.number(),
|
||||||
IsFolder: z.boolean(),
|
IsFolder: z.boolean(),
|
||||||
|
LUFS: z.number().optional(),
|
||||||
LocationType: z.string(),
|
LocationType: z.string(),
|
||||||
MediaSources: z.array(mediaSources),
|
MediaSources: z.array(mediaSources),
|
||||||
MediaType: z.string(),
|
MediaType: z.string(),
|
||||||
@@ -450,7 +473,7 @@ const album = z.object({
|
|||||||
UserData: userData.optional(),
|
UserData: userData.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const jfAlbumListSort = {
|
const albumListSort = {
|
||||||
ALBUM_ARTIST: 'AlbumArtist,SortName',
|
ALBUM_ARTIST: 'AlbumArtist,SortName',
|
||||||
COMMUNITY_RATING: 'CommunityRating,SortName',
|
COMMUNITY_RATING: 'CommunityRating,SortName',
|
||||||
CRITIC_RATING: 'CriticRating,SortName',
|
CRITIC_RATING: 'CriticRating,SortName',
|
||||||
@@ -468,7 +491,7 @@ const albumListParameters = paginationParameters.merge(
|
|||||||
IncludeItemTypes: z.literal('MusicAlbum'),
|
IncludeItemTypes: z.literal('MusicAlbum'),
|
||||||
IsFavorite: z.boolean().optional(),
|
IsFavorite: z.boolean().optional(),
|
||||||
SearchTerm: z.string().optional(),
|
SearchTerm: z.string().optional(),
|
||||||
SortBy: z.nativeEnum(jfAlbumListSort).optional(),
|
SortBy: z.nativeEnum(albumListSort).optional(),
|
||||||
Tags: z.string().optional(),
|
Tags: z.string().optional(),
|
||||||
Years: z.string().optional(),
|
Years: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
@@ -478,7 +501,7 @@ const albumList = pagination.extend({
|
|||||||
Items: z.array(album),
|
Items: z.array(album),
|
||||||
});
|
});
|
||||||
|
|
||||||
const jfAlbumArtistListSort = {
|
const albumArtistListSort = {
|
||||||
ALBUM: 'Album,SortName',
|
ALBUM: 'Album,SortName',
|
||||||
DURATION: 'Runtime,AlbumArtist,Album,SortName',
|
DURATION: 'Runtime,AlbumArtist,Album,SortName',
|
||||||
NAME: 'Name,SortName',
|
NAME: 'Name,SortName',
|
||||||
@@ -491,7 +514,7 @@ const albumArtistListParameters = paginationParameters.merge(
|
|||||||
baseParameters.extend({
|
baseParameters.extend({
|
||||||
Filters: z.string().optional(),
|
Filters: z.string().optional(),
|
||||||
Genres: z.string().optional(),
|
Genres: z.string().optional(),
|
||||||
SortBy: z.nativeEnum(jfAlbumArtistListSort).optional(),
|
SortBy: z.nativeEnum(albumArtistListSort).optional(),
|
||||||
Years: z.string().optional(),
|
Years: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -504,9 +527,10 @@ const similarArtistListParameters = baseParameters.extend({
|
|||||||
Limit: z.number().optional(),
|
Limit: z.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const jfSongListSort = {
|
const songListSort = {
|
||||||
ALBUM: 'Album,SortName',
|
ALBUM: 'Album,SortName',
|
||||||
ALBUM_ARTIST: 'AlbumArtist,Album,SortName',
|
ALBUM_ARTIST: 'AlbumArtist,Album,SortName',
|
||||||
|
ALBUM_DETAIL: 'ParentIndexNumber,IndexNumber,SortName',
|
||||||
ARTIST: 'Artist,Album,SortName',
|
ARTIST: 'Artist,Album,SortName',
|
||||||
COMMUNITY_RATING: 'CommunityRating,SortName',
|
COMMUNITY_RATING: 'CommunityRating,SortName',
|
||||||
DURATION: 'Runtime,AlbumArtist,Album,SortName',
|
DURATION: 'Runtime,AlbumArtist,Album,SortName',
|
||||||
@@ -518,7 +542,8 @@ const jfSongListSort = {
|
|||||||
RELEASE_DATE: 'PremiereDate,AlbumArtist,Album,SortName',
|
RELEASE_DATE: 'PremiereDate,AlbumArtist,Album,SortName',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const songListParameters = baseParameters.extend({
|
const songListParameters = paginationParameters.merge(
|
||||||
|
baseParameters.extend({
|
||||||
AlbumArtistIds: z.string().optional(),
|
AlbumArtistIds: z.string().optional(),
|
||||||
AlbumIds: z.string().optional(),
|
AlbumIds: z.string().optional(),
|
||||||
ArtistIds: z.string().optional(),
|
ArtistIds: z.string().optional(),
|
||||||
@@ -527,10 +552,11 @@ const songListParameters = baseParameters.extend({
|
|||||||
Genres: z.string().optional(),
|
Genres: z.string().optional(),
|
||||||
IsFavorite: z.boolean().optional(),
|
IsFavorite: z.boolean().optional(),
|
||||||
SearchTerm: z.string().optional(),
|
SearchTerm: z.string().optional(),
|
||||||
SortBy: z.nativeEnum(jfSongListSort).optional(),
|
SortBy: z.nativeEnum(songListSort).optional(),
|
||||||
Tags: z.string().optional(),
|
Tags: z.string().optional(),
|
||||||
Years: z.string().optional(),
|
Years: z.string().optional(),
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const songList = pagination.extend({
|
const songList = pagination.extend({
|
||||||
Items: z.array(song),
|
Items: z.array(song),
|
||||||
@@ -614,11 +640,29 @@ const favorite = z.object({
|
|||||||
|
|
||||||
const favoriteParameters = z.object({});
|
const favoriteParameters = z.object({});
|
||||||
|
|
||||||
|
const searchParameters = paginationParameters.merge(baseParameters);
|
||||||
|
|
||||||
|
const search = z.any();
|
||||||
|
|
||||||
|
const lyricText = z.object({
|
||||||
|
Start: z.number().optional(),
|
||||||
|
Text: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const lyrics = z.object({
|
||||||
|
Lyrics: z.array(lyricText),
|
||||||
|
});
|
||||||
|
|
||||||
export const jfType = {
|
export const jfType = {
|
||||||
_enum: {
|
_enum: {
|
||||||
|
albumArtistList: albumArtistListSort,
|
||||||
|
albumList: albumListSort,
|
||||||
collection: jfCollection,
|
collection: jfCollection,
|
||||||
external: jfExternal,
|
external: jfExternal,
|
||||||
|
genreList: genreListSort,
|
||||||
image: jfImage,
|
image: jfImage,
|
||||||
|
playlistList: playlistListSort,
|
||||||
|
songList: songListSort,
|
||||||
},
|
},
|
||||||
_parameters: {
|
_parameters: {
|
||||||
addToPlaylist: addToPlaylistParameters,
|
addToPlaylist: addToPlaylistParameters,
|
||||||
@@ -630,11 +674,13 @@ export const jfType = {
|
|||||||
createPlaylist: createPlaylistParameters,
|
createPlaylist: createPlaylistParameters,
|
||||||
deletePlaylist: deletePlaylistParameters,
|
deletePlaylist: deletePlaylistParameters,
|
||||||
favorite: favoriteParameters,
|
favorite: favoriteParameters,
|
||||||
|
genreList: genreListParameters,
|
||||||
musicFolderList: musicFolderListParameters,
|
musicFolderList: musicFolderListParameters,
|
||||||
playlistDetail: playlistDetailParameters,
|
playlistDetail: playlistDetailParameters,
|
||||||
playlistList: playlistListParameters,
|
playlistList: playlistListParameters,
|
||||||
removeFromPlaylist: removeFromPlaylistParameters,
|
removeFromPlaylist: removeFromPlaylistParameters,
|
||||||
scrobble: scrobbleParameters,
|
scrobble: scrobbleParameters,
|
||||||
|
search: searchParameters,
|
||||||
similarArtistList: similarArtistListParameters,
|
similarArtistList: similarArtistListParameters,
|
||||||
songList: songListParameters,
|
songList: songListParameters,
|
||||||
updatePlaylist: updatePlaylistParameters,
|
updatePlaylist: updatePlaylistParameters,
|
||||||
@@ -652,12 +698,14 @@ export const jfType = {
|
|||||||
favorite,
|
favorite,
|
||||||
genre,
|
genre,
|
||||||
genreList,
|
genreList,
|
||||||
|
lyrics,
|
||||||
musicFolderList,
|
musicFolderList,
|
||||||
playlist,
|
playlist,
|
||||||
playlistList,
|
playlistList,
|
||||||
playlistSongList,
|
playlistSongList,
|
||||||
removeFromPlaylist,
|
removeFromPlaylist,
|
||||||
scrobble,
|
scrobble,
|
||||||
|
search,
|
||||||
song,
|
song,
|
||||||
songList,
|
songList,
|
||||||
topSongsList,
|
topSongsList,
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { initClient, initContract } from '@ts-rest/core';
|
import { initClient, initContract } from '@ts-rest/core';
|
||||||
import axios, { Method, AxiosError, AxiosResponse, isAxiosError } from 'axios';
|
import axios, { Method, AxiosError, AxiosResponse, isAxiosError } from 'axios';
|
||||||
|
import isElectron from 'is-electron';
|
||||||
|
import debounce from 'lodash/debounce';
|
||||||
import omitBy from 'lodash/omitBy';
|
import omitBy from 'lodash/omitBy';
|
||||||
import qs from 'qs';
|
import qs from 'qs';
|
||||||
import { ndType } from './navidrome-types';
|
import { ndType } from './navidrome-types';
|
||||||
import { resultWithHeaders } from '/@/renderer/api/utils';
|
import { authenticationFailure, resultWithHeaders } from '/@/renderer/api/utils';
|
||||||
import { toast } from '/@/renderer/components/toast/index';
|
|
||||||
import { useAuthStore } from '/@/renderer/store';
|
import { useAuthStore } from '/@/renderer/store';
|
||||||
import { ServerListItem } from '/@/renderer/types';
|
import { ServerListItem } from '/@/renderer/types';
|
||||||
|
import { toast } from '/@/renderer/components';
|
||||||
|
|
||||||
|
const localSettings = isElectron() ? window.electron.localSettings : null;
|
||||||
|
|
||||||
const c = initContract();
|
const c = initContract();
|
||||||
|
|
||||||
@@ -84,6 +88,7 @@ export const contract = c.router({
|
|||||||
getGenreList: {
|
getGenreList: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: 'genre',
|
path: 'genre',
|
||||||
|
query: ndType._parameters.genreList,
|
||||||
responses: {
|
responses: {
|
||||||
200: resultWithHeaders(ndType._response.genreList),
|
200: resultWithHeaders(ndType._response.genreList),
|
||||||
500: resultWithHeaders(ndType._response.error),
|
500: resultWithHeaders(ndType._response.error),
|
||||||
@@ -168,44 +173,26 @@ axiosClient.defaults.paramsSerializer = (params) => {
|
|||||||
return qs.stringify(params, { arrayFormat: 'repeat' });
|
return qs.stringify(params, { arrayFormat: 'repeat' });
|
||||||
};
|
};
|
||||||
|
|
||||||
axiosClient.interceptors.response.use(
|
|
||||||
(response) => {
|
|
||||||
const serverId = useAuthStore.getState().currentServer?.id;
|
|
||||||
|
|
||||||
if (serverId) {
|
|
||||||
useAuthStore.getState().actions.updateServer(serverId, {
|
|
||||||
ndCredential: response.headers['x-nd-authorization'] as string,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
if (error.response && error.response.status === 401) {
|
|
||||||
toast.error({
|
|
||||||
message: 'Your session has expired.',
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentServer = useAuthStore.getState().currentServer;
|
|
||||||
|
|
||||||
if (currentServer) {
|
|
||||||
const serverId = currentServer.id;
|
|
||||||
const token = currentServer.ndCredential;
|
|
||||||
console.log(`token is expired: ${token}`);
|
|
||||||
useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined });
|
|
||||||
useAuthStore.getState().actions.setCurrentServer(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.reject(error);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const parsePath = (fullPath: string) => {
|
const parsePath = (fullPath: string) => {
|
||||||
const [path, params] = fullPath.split('?');
|
const [path, params] = fullPath.split('?');
|
||||||
|
|
||||||
const parsedParams = qs.parse(params);
|
const parsedParams = qs.parse(params);
|
||||||
const notNilParams = omitBy(parsedParams, (value) => value === 'undefined' || value === 'null');
|
|
||||||
|
// Convert indexed object to array
|
||||||
|
const newParams: Record<string, any> = {};
|
||||||
|
Object.keys(parsedParams).forEach((key) => {
|
||||||
|
const isIndexedArrayObject =
|
||||||
|
typeof parsedParams[key] === 'object' &&
|
||||||
|
Object.keys(parsedParams[key] || {}).includes('0');
|
||||||
|
|
||||||
|
if (!isIndexedArrayObject) {
|
||||||
|
newParams[key] = parsedParams[key];
|
||||||
|
} else {
|
||||||
|
newParams[key] = Object.values(parsedParams[key] || {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const notNilParams = omitBy(newParams, (value) => value === 'undefined' || value === 'null');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
params: notNilParams,
|
params: notNilParams,
|
||||||
@@ -213,6 +200,136 @@ const parsePath = (fullPath: string) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let authSuccess = true;
|
||||||
|
let shouldDelay = false;
|
||||||
|
|
||||||
|
const RETRY_DELAY_MS = 1000;
|
||||||
|
const MAX_RETRIES = 5;
|
||||||
|
|
||||||
|
const waitForResult = async (count = 0): Promise<void> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (count === MAX_RETRIES || !shouldDelay) resolve();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
waitForResult(count + 1)
|
||||||
|
.then(resolve)
|
||||||
|
.catch(resolve);
|
||||||
|
}, RETRY_DELAY_MS);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const limitedFail = debounce(authenticationFailure, RETRY_DELAY_MS);
|
||||||
|
const TIMEOUT_ERROR = Error();
|
||||||
|
|
||||||
|
axiosClient.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
const serverId = useAuthStore.getState().currentServer?.id;
|
||||||
|
|
||||||
|
if (serverId) {
|
||||||
|
const headerCredential = response.headers['x-nd-authorization'] as string | undefined;
|
||||||
|
|
||||||
|
if (headerCredential) {
|
||||||
|
useAuthStore.getState().actions.updateServer(serverId, {
|
||||||
|
ndCredential: headerCredential,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
authSuccess = true;
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
if (error.response && error.response.status === 401) {
|
||||||
|
const currentServer = useAuthStore.getState().currentServer;
|
||||||
|
|
||||||
|
if (localSettings && currentServer?.savePassword) {
|
||||||
|
// eslint-disable-next-line promise/no-promise-in-callback
|
||||||
|
return localSettings
|
||||||
|
.passwordGet(currentServer.id)
|
||||||
|
.then(async (password: string | null) => {
|
||||||
|
authSuccess = false;
|
||||||
|
|
||||||
|
if (password === null) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldDelay) {
|
||||||
|
await waitForResult();
|
||||||
|
|
||||||
|
// Hopefully the delay was sufficient for authentication.
|
||||||
|
// Otherwise, it will require manual intervention
|
||||||
|
if (authSuccess) {
|
||||||
|
return axiosClient.request(error.config);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldDelay = true;
|
||||||
|
|
||||||
|
// Do not use axiosClient. Instead, manually make a post
|
||||||
|
const res = await axios.post(`${currentServer.url}/auth/login`, {
|
||||||
|
password,
|
||||||
|
username: currentServer.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 429) {
|
||||||
|
toast.error({
|
||||||
|
message:
|
||||||
|
'you have exceeded the number of allowed login requests. Please wait before logging, or consider tweaking AuthRequestLimit',
|
||||||
|
title: 'Your session has expired.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const serverId = currentServer.id;
|
||||||
|
useAuthStore
|
||||||
|
.getState()
|
||||||
|
.actions.updateServer(serverId, { ndCredential: undefined });
|
||||||
|
useAuthStore.getState().actions.setCurrentServer(null);
|
||||||
|
|
||||||
|
// special error to prevent sending a second message, and stop other messages that could be enqueued
|
||||||
|
limitedFail.cancel();
|
||||||
|
throw TIMEOUT_ERROR;
|
||||||
|
}
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to authenticate');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCredential = res.data.token;
|
||||||
|
const subsonicCredential = `u=${currentServer.username}&s=${res.data.subsonicSalt}&t=${res.data.subsonicToken}`;
|
||||||
|
|
||||||
|
useAuthStore.getState().actions.updateServer(currentServer.id, {
|
||||||
|
credential: subsonicCredential,
|
||||||
|
ndCredential: newCredential,
|
||||||
|
});
|
||||||
|
|
||||||
|
error.config.headers['x-nd-authorization'] = `Bearer ${newCredential}`;
|
||||||
|
|
||||||
|
authSuccess = true;
|
||||||
|
|
||||||
|
return axiosClient.request(error.config);
|
||||||
|
})
|
||||||
|
.catch((newError: any) => {
|
||||||
|
if (newError !== TIMEOUT_ERROR) {
|
||||||
|
console.error('Error when trying to reauthenticate: ', newError);
|
||||||
|
limitedFail(currentServer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// make sure to pass the error so axios will error later on
|
||||||
|
throw newError;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
shouldDelay = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
limitedFail(currentServer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const ndApiClient = (args: {
|
export const ndApiClient = (args: {
|
||||||
server: ServerListItem | null;
|
server: ServerListItem | null;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
@@ -235,6 +352,8 @@ export const ndApiClient = (args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (shouldDelay) await waitForResult();
|
||||||
|
|
||||||
const result = await axiosClient.request({
|
const result = await axiosClient.request({
|
||||||
data: body,
|
data: body,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -248,6 +367,7 @@ export const ndApiClient = (args: {
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
body: { data: result.data, headers: result.headers },
|
body: { data: result.data, headers: result.headers },
|
||||||
|
headers: result.headers as any,
|
||||||
status: result.status,
|
status: result.status,
|
||||||
};
|
};
|
||||||
} catch (e: Error | AxiosError | any) {
|
} catch (e: Error | AxiosError | any) {
|
||||||
@@ -256,6 +376,7 @@ export const ndApiClient = (args: {
|
|||||||
const response = error.response as AxiosResponse;
|
const response = error.response as AxiosResponse;
|
||||||
return {
|
return {
|
||||||
body: { data: response.data, headers: response.headers },
|
body: { data: response.data, headers: response.headers },
|
||||||
|
headers: response.headers as any,
|
||||||
status: response.status,
|
status: response.status,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,10 +38,12 @@ import {
|
|||||||
PlaylistSongListResponse,
|
PlaylistSongListResponse,
|
||||||
RemoveFromPlaylistResponse,
|
RemoveFromPlaylistResponse,
|
||||||
RemoveFromPlaylistArgs,
|
RemoveFromPlaylistArgs,
|
||||||
|
genreListSortMap,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
|
import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
|
||||||
import { ndNormalize } from '/@/renderer/api/navidrome/navidrome-normalize';
|
import { ndNormalize } from '/@/renderer/api/navidrome/navidrome-normalize';
|
||||||
import { ndType } from '/@/renderer/api/navidrome/navidrome-types';
|
import { ndType } from '/@/renderer/api/navidrome/navidrome-types';
|
||||||
|
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
|
||||||
|
|
||||||
const authenticate = async (
|
const authenticate = async (
|
||||||
url: string,
|
url: string,
|
||||||
@@ -93,17 +95,25 @@ const getUserList = async (args: UserListArgs): Promise<UserListResponse> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getGenreList = async (args: GenreListArgs): Promise<GenreListResponse> => {
|
const getGenreList = async (args: GenreListArgs): Promise<GenreListResponse> => {
|
||||||
const { apiClientProps } = args;
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
const res = await ndApiClient(apiClientProps).getGenreList({});
|
const res = await ndApiClient(apiClientProps).getGenreList({
|
||||||
|
query: {
|
||||||
|
_end: query.startIndex + (query.limit || 0),
|
||||||
|
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||||
|
_sort: genreListSortMap.navidrome[query.sortBy],
|
||||||
|
_start: query.startIndex,
|
||||||
|
name: query.searchTerm,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (res.status !== 200) {
|
if (res.status !== 200) {
|
||||||
throw new Error('Failed to get genre list');
|
throw new Error('Failed to get genre list');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: res.body.data,
|
items: res.body.data.map((genre) => ndNormalize.genre(genre)),
|
||||||
startIndex: 0,
|
startIndex: query.startIndex || 0,
|
||||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -119,6 +129,13 @@ const getAlbumArtistDetail = async (
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const artistInfoRes = await ssApiClient(apiClientProps).getArtistInfo({
|
||||||
|
query: {
|
||||||
|
count: 10,
|
||||||
|
id: query.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (res.status !== 200) {
|
if (res.status !== 200) {
|
||||||
throw new Error('Failed to get album artist detail');
|
throw new Error('Failed to get album artist detail');
|
||||||
}
|
}
|
||||||
@@ -127,7 +144,24 @@ const getAlbumArtistDetail = async (
|
|||||||
throw new Error('Server is required');
|
throw new Error('Server is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
return ndNormalize.albumArtist(res.body.data, apiClientProps.server);
|
return ndNormalize.albumArtist(
|
||||||
|
{
|
||||||
|
...res.body.data,
|
||||||
|
...(artistInfoRes.status === 200 && {
|
||||||
|
similarArtists: artistInfoRes.body.artistInfo.similarArtist,
|
||||||
|
...(!res.body.data.largeImageUrl && {
|
||||||
|
largeImageUrl: artistInfoRes.body.artistInfo.largeImageUrl,
|
||||||
|
}),
|
||||||
|
...(!res.body.data.mediumImageUrl && {
|
||||||
|
largeImageUrl: artistInfoRes.body.artistInfo.mediumImageUrl,
|
||||||
|
}),
|
||||||
|
...(!res.body.data.smallImageUrl && {
|
||||||
|
largeImageUrl: artistInfoRes.body.artistInfo.smallImageUrl,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
apiClientProps.server,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<AlbumArtistListResponse> => {
|
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<AlbumArtistListResponse> => {
|
||||||
@@ -221,8 +255,8 @@ const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
|
|||||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||||
_sort: songListSortMap.navidrome[query.sortBy],
|
_sort: songListSortMap.navidrome[query.sortBy],
|
||||||
_start: query.startIndex,
|
_start: query.startIndex,
|
||||||
|
album_artist_id: query.artistIds,
|
||||||
album_id: query.albumIds,
|
album_id: query.albumIds,
|
||||||
artist_id: query.artistIds,
|
|
||||||
title: query.searchTerm,
|
title: query.searchTerm,
|
||||||
...query._custom?.navidrome,
|
...query._custom?.navidrome,
|
||||||
},
|
},
|
||||||
@@ -233,7 +267,9 @@ const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: res.body.data.map((song) => ndNormalize.song(song, apiClientProps.server, '')),
|
items: res.body.data.map((song) =>
|
||||||
|
ndNormalize.song(song, apiClientProps.server, '', query.imageSize),
|
||||||
|
),
|
||||||
startIndex: query?.startIndex || 0,
|
startIndex: query?.startIndex || 0,
|
||||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||||
};
|
};
|
||||||
@@ -326,6 +362,7 @@ const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResp
|
|||||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||||
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined,
|
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined,
|
||||||
_start: query.startIndex,
|
_start: query.startIndex,
|
||||||
|
q: query.searchTerm,
|
||||||
...query._custom?.navidrome,
|
...query._custom?.navidrome,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -369,7 +406,9 @@ const getPlaylistSongList = async (
|
|||||||
query: {
|
query: {
|
||||||
_end: query.startIndex + (query.limit || 0),
|
_end: query.startIndex + (query.limit || 0),
|
||||||
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : 'ASC',
|
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : 'ASC',
|
||||||
_sort: query.sortBy ? songListSortMap.navidrome[query.sortBy] : ndType._enum.songList.ID,
|
_sort: query.sortBy
|
||||||
|
? songListSortMap.navidrome[query.sortBy]
|
||||||
|
: ndType._enum.songList.ID,
|
||||||
_start: query.startIndex,
|
_start: query.startIndex,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -415,7 +454,7 @@ const removeFromPlaylist = async (
|
|||||||
id: query.id,
|
id: query.id,
|
||||||
},
|
},
|
||||||
query: {
|
query: {
|
||||||
ids: query.songId,
|
id: query.songId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,31 @@
|
|||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { Song, LibraryItem, Album, AlbumArtist, Playlist, User } from '/@/renderer/api/types';
|
import {
|
||||||
|
Song,
|
||||||
|
LibraryItem,
|
||||||
|
Album,
|
||||||
|
Playlist,
|
||||||
|
User,
|
||||||
|
AlbumArtist,
|
||||||
|
Genre,
|
||||||
|
} from '/@/renderer/api/types';
|
||||||
import { ServerListItem, ServerType } from '/@/renderer/types';
|
import { ServerListItem, ServerType } from '/@/renderer/types';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
import { ndType } from './navidrome-types';
|
import { ndType } from './navidrome-types';
|
||||||
|
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
|
||||||
|
import { NDGenre } from '/@/renderer/api/navidrome.types';
|
||||||
|
|
||||||
|
const getImageUrl = (args: { url: string | null }) => {
|
||||||
|
const { url } = args;
|
||||||
|
if (url === '/app/artist-placeholder.webp') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url?.match('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
const getCoverArtUrl = (args: {
|
const getCoverArtUrl = (args: {
|
||||||
baseUrl: string | undefined;
|
baseUrl: string | undefined;
|
||||||
@@ -51,7 +74,6 @@ const normalizeSong = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
const imagePlaceholderUrl = null;
|
const imagePlaceholderUrl = null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
album: item.album,
|
album: item.album,
|
||||||
albumArtists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
albumArtists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||||
@@ -66,15 +88,30 @@ const normalizeSong = (
|
|||||||
container: item.suffix,
|
container: item.suffix,
|
||||||
createdAt: item.createdAt.split('T')[0],
|
createdAt: item.createdAt.split('T')[0],
|
||||||
discNumber: item.discNumber,
|
discNumber: item.discNumber,
|
||||||
duration: item.duration,
|
discSubtitle: item.discSubtitle ? item.discSubtitle : null,
|
||||||
genres: item.genres,
|
duration: item.duration * 1000,
|
||||||
|
gain:
|
||||||
|
item.rgAlbumGain || item.rgTrackGain
|
||||||
|
? { album: item.rgAlbumGain, track: item.rgTrackGain }
|
||||||
|
: null,
|
||||||
|
genres: item.genres?.map((genre) => ({
|
||||||
|
id: genre.id,
|
||||||
|
imageUrl: null,
|
||||||
|
itemType: LibraryItem.GENRE,
|
||||||
|
name: genre.name,
|
||||||
|
})),
|
||||||
id,
|
id,
|
||||||
imagePlaceholderUrl,
|
imagePlaceholderUrl,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
itemType: LibraryItem.SONG,
|
itemType: LibraryItem.SONG,
|
||||||
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
|
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
|
||||||
|
lyrics: item.lyrics ? item.lyrics : null,
|
||||||
name: item.title,
|
name: item.title,
|
||||||
path: item.path,
|
path: item.path,
|
||||||
|
peak:
|
||||||
|
item.rgAlbumPeak || item.rgTrackPeak
|
||||||
|
? { album: item.rgAlbumPeak, track: item.rgTrackPeak }
|
||||||
|
: null,
|
||||||
playCount: item.playCount,
|
playCount: item.playCount,
|
||||||
playlistItemId,
|
playlistItemId,
|
||||||
releaseDate: new Date(item.year, 0, 1).toISOString(),
|
releaseDate: new Date(item.year, 0, 1).toISOString(),
|
||||||
@@ -115,7 +152,12 @@ const normalizeAlbum = (
|
|||||||
backdropImageUrl: imageBackdropUrl,
|
backdropImageUrl: imageBackdropUrl,
|
||||||
createdAt: item.createdAt.split('T')[0],
|
createdAt: item.createdAt.split('T')[0],
|
||||||
duration: item.duration * 1000 || null,
|
duration: item.duration * 1000 || null,
|
||||||
genres: item.genres,
|
genres: item.genres?.map((genre) => ({
|
||||||
|
id: genre.id,
|
||||||
|
imageUrl: null,
|
||||||
|
itemType: LibraryItem.GENRE,
|
||||||
|
name: genre.name,
|
||||||
|
})),
|
||||||
id: item.id,
|
id: item.id,
|
||||||
imagePlaceholderUrl,
|
imagePlaceholderUrl,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
@@ -139,18 +181,24 @@ const normalizeAlbum = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const normalizeAlbumArtist = (
|
const normalizeAlbumArtist = (
|
||||||
item: z.infer<typeof ndType._response.albumArtist>,
|
item: z.infer<typeof ndType._response.albumArtist> & {
|
||||||
|
similarArtists?: z.infer<typeof ssType._response.artistInfo>['artistInfo']['similarArtist'];
|
||||||
|
},
|
||||||
server: ServerListItem | null,
|
server: ServerListItem | null,
|
||||||
): AlbumArtist => {
|
): AlbumArtist => {
|
||||||
const imageUrl =
|
const imageUrl = getImageUrl({ url: item?.largeImageUrl || null });
|
||||||
item.largeImageUrl === '/app/artist-placeholder.webp' ? null : item.largeImageUrl;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
albumCount: item.albumCount,
|
albumCount: item.albumCount,
|
||||||
backgroundImageUrl: null,
|
backgroundImageUrl: null,
|
||||||
biography: item.biography || null,
|
biography: item.biography || null,
|
||||||
duration: null,
|
duration: null,
|
||||||
genres: item.genres,
|
genres: item.genres?.map((genre) => ({
|
||||||
|
id: genre.id,
|
||||||
|
imageUrl: null,
|
||||||
|
itemType: LibraryItem.GENRE,
|
||||||
|
name: genre.name,
|
||||||
|
})),
|
||||||
id: item.id,
|
id: item.id,
|
||||||
imageUrl: imageUrl || null,
|
imageUrl: imageUrl || null,
|
||||||
itemType: LibraryItem.ALBUM_ARTIST,
|
itemType: LibraryItem.ALBUM_ARTIST,
|
||||||
@@ -159,13 +207,12 @@ const normalizeAlbumArtist = (
|
|||||||
playCount: item.playCount,
|
playCount: item.playCount,
|
||||||
serverId: server?.id || 'unknown',
|
serverId: server?.id || 'unknown',
|
||||||
serverType: ServerType.NAVIDROME,
|
serverType: ServerType.NAVIDROME,
|
||||||
similarArtists: null,
|
similarArtists:
|
||||||
// similarArtists:
|
item.similarArtists?.map((artist) => ({
|
||||||
// item.similarArtists?.map((artist) => ({
|
id: artist.id,
|
||||||
// id: artist.id,
|
imageUrl: artist?.artistImageUrl || null,
|
||||||
// imageUrl: artist?.artistImageUrl || null,
|
name: artist.name,
|
||||||
// name: artist.name,
|
})) || null,
|
||||||
// })) || null,
|
|
||||||
songCount: item.songCount,
|
songCount: item.songCount,
|
||||||
userFavorite: item.starred,
|
userFavorite: item.starred,
|
||||||
userRating: item.rating,
|
userRating: item.rating,
|
||||||
@@ -207,6 +254,17 @@ const normalizePlaylist = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeGenre = (item: NDGenre): Genre => {
|
||||||
|
return {
|
||||||
|
albumCount: undefined,
|
||||||
|
id: item.id,
|
||||||
|
imageUrl: null,
|
||||||
|
itemType: LibraryItem.GENRE,
|
||||||
|
name: item.name,
|
||||||
|
songCount: undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeUser = (item: z.infer<typeof ndType._response.user>): User => {
|
const normalizeUser = (item: z.infer<typeof ndType._response.user>): User => {
|
||||||
return {
|
return {
|
||||||
createdAt: item.createdAt,
|
createdAt: item.createdAt,
|
||||||
@@ -222,6 +280,7 @@ const normalizeUser = (item: z.infer<typeof ndType._response.user>): User => {
|
|||||||
export const ndNormalize = {
|
export const ndNormalize = {
|
||||||
album: normalizeAlbum,
|
album: normalizeAlbum,
|
||||||
albumArtist: normalizeAlbumArtist,
|
albumArtist: normalizeAlbumArtist,
|
||||||
|
genre: normalizeGenre,
|
||||||
playlist: normalizePlaylist,
|
playlist: normalizePlaylist,
|
||||||
song: normalizeSong,
|
song: normalizeSong,
|
||||||
user: normalizeUser,
|
user: normalizeUser,
|
||||||
|
|||||||
@@ -52,6 +52,16 @@ const genre = z.object({
|
|||||||
name: z.string(),
|
name: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const genreListSort = {
|
||||||
|
NAME: 'name',
|
||||||
|
SONG_COUNT: 'songCount',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const genreListParameters = paginationParameters.extend({
|
||||||
|
_sort: z.nativeEnum(genreListSort).optional(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const genreList = z.array(genre);
|
const genreList = z.array(genre);
|
||||||
|
|
||||||
const albumArtist = z.object({
|
const albumArtist = z.object({
|
||||||
@@ -156,6 +166,7 @@ const albumListParameters = paginationParameters.extend({
|
|||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
recently_added: z.boolean().optional(),
|
recently_added: z.boolean().optional(),
|
||||||
|
recently_played: z.boolean().optional(),
|
||||||
starred: z.boolean().optional(),
|
starred: z.boolean().optional(),
|
||||||
year: z.number().optional(),
|
year: z.number().optional(),
|
||||||
});
|
});
|
||||||
@@ -170,22 +181,30 @@ const song = z.object({
|
|||||||
bitRate: z.number(),
|
bitRate: z.number(),
|
||||||
bookmarkPosition: z.number(),
|
bookmarkPosition: z.number(),
|
||||||
bpm: z.number().optional(),
|
bpm: z.number().optional(),
|
||||||
|
catalogNum: z.string().optional(),
|
||||||
channels: z.number().optional(),
|
channels: z.number().optional(),
|
||||||
comment: z.string().optional(),
|
comment: z.string().optional(),
|
||||||
compilation: z.boolean(),
|
compilation: z.boolean(),
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
discNumber: z.number(),
|
discNumber: z.number(),
|
||||||
|
discSubtitle: z.string().optional(),
|
||||||
duration: z.number(),
|
duration: z.number(),
|
||||||
|
embedArtPath: z.string().optional(),
|
||||||
|
externalInfoUpdatedAt: z.string().optional(),
|
||||||
|
externalUrl: z.string().optional(),
|
||||||
fullText: z.string(),
|
fullText: z.string(),
|
||||||
genre: z.string(),
|
genre: z.string(),
|
||||||
genres: z.array(genre),
|
genres: z.array(genre),
|
||||||
hasCoverArt: z.boolean(),
|
hasCoverArt: z.boolean(),
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
|
imageFiles: z.string().optional(),
|
||||||
|
largeImageUrl: z.string().optional(),
|
||||||
lyrics: z.string().optional(),
|
lyrics: z.string().optional(),
|
||||||
mbzAlbumArtistId: z.string().optional(),
|
mbzAlbumArtistId: z.string().optional(),
|
||||||
mbzAlbumId: z.string().optional(),
|
mbzAlbumId: z.string().optional(),
|
||||||
mbzArtistId: z.string().optional(),
|
mbzArtistId: z.string().optional(),
|
||||||
mbzTrackId: z.string().optional(),
|
mbzTrackId: z.string().optional(),
|
||||||
|
mediumImageUrl: z.string().optional(),
|
||||||
orderAlbumArtistName: z.string(),
|
orderAlbumArtistName: z.string(),
|
||||||
orderAlbumName: z.string(),
|
orderAlbumName: z.string(),
|
||||||
orderArtistName: z.string(),
|
orderArtistName: z.string(),
|
||||||
@@ -194,7 +213,12 @@ const song = z.object({
|
|||||||
playCount: z.number(),
|
playCount: z.number(),
|
||||||
playDate: z.string(),
|
playDate: z.string(),
|
||||||
rating: z.number().optional(),
|
rating: z.number().optional(),
|
||||||
|
rgAlbumGain: z.number().optional(),
|
||||||
|
rgAlbumPeak: z.number().optional(),
|
||||||
|
rgTrackGain: z.number().optional(),
|
||||||
|
rgTrackPeak: z.number().optional(),
|
||||||
size: z.number(),
|
size: z.number(),
|
||||||
|
smallImageUrl: z.string().optional(),
|
||||||
sortAlbumArtistName: z.string(),
|
sortAlbumArtistName: z.string(),
|
||||||
sortArtistName: z.string(),
|
sortArtistName: z.string(),
|
||||||
starred: z.boolean(),
|
starred: z.boolean(),
|
||||||
@@ -231,10 +255,14 @@ const ndSongListSort = {
|
|||||||
|
|
||||||
const songListParameters = paginationParameters.extend({
|
const songListParameters = paginationParameters.extend({
|
||||||
_sort: z.nativeEnum(ndSongListSort).optional(),
|
_sort: z.nativeEnum(ndSongListSort).optional(),
|
||||||
|
album_artist_id: z.array(z.string()).optional(),
|
||||||
album_id: z.array(z.string()).optional(),
|
album_id: z.array(z.string()).optional(),
|
||||||
artist_id: z.array(z.string()).optional(),
|
artist_id: z.array(z.string()).optional(),
|
||||||
genre_id: z.string().optional(),
|
genre_id: z.string().optional(),
|
||||||
|
path: z.string().optional(),
|
||||||
starred: z.boolean().optional(),
|
starred: z.boolean().optional(),
|
||||||
|
title: z.string().optional(),
|
||||||
|
year: z.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const playlist = z.object({
|
const playlist = z.object({
|
||||||
@@ -269,6 +297,8 @@ const ndPlaylistListSort = {
|
|||||||
const playlistListParameters = paginationParameters.extend({
|
const playlistListParameters = paginationParameters.extend({
|
||||||
_sort: z.nativeEnum(ndPlaylistListSort).optional(),
|
_sort: z.nativeEnum(ndPlaylistListSort).optional(),
|
||||||
owner_id: z.string().optional(),
|
owner_id: z.string().optional(),
|
||||||
|
q: z.string().optional(),
|
||||||
|
smart: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const playlistSong = song.extend({
|
const playlistSong = song.extend({
|
||||||
@@ -309,13 +339,14 @@ const removeFromPlaylist = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const removeFromPlaylistParameters = z.object({
|
const removeFromPlaylistParameters = z.object({
|
||||||
ids: z.array(z.string()),
|
id: z.array(z.string()),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ndType = {
|
export const ndType = {
|
||||||
_enum: {
|
_enum: {
|
||||||
albumArtistList: ndAlbumArtistListSort,
|
albumArtistList: ndAlbumArtistListSort,
|
||||||
albumList: ndAlbumListSort,
|
albumList: ndAlbumListSort,
|
||||||
|
genreList: genreListSort,
|
||||||
playlistList: ndPlaylistListSort,
|
playlistList: ndPlaylistListSort,
|
||||||
songList: ndSongListSort,
|
songList: ndSongListSort,
|
||||||
userList: ndUserListSort,
|
userList: ndUserListSort,
|
||||||
@@ -326,6 +357,7 @@ export const ndType = {
|
|||||||
albumList: albumListParameters,
|
albumList: albumListParameters,
|
||||||
authenticate: authenticateParameters,
|
authenticate: authenticateParameters,
|
||||||
createPlaylist: createPlaylistParameters,
|
createPlaylist: createPlaylistParameters,
|
||||||
|
genreList: genreListParameters,
|
||||||
playlistList: playlistListParameters,
|
playlistList: playlistListParameters,
|
||||||
removeFromPlaylist: removeFromPlaylistParameters,
|
removeFromPlaylist: removeFromPlaylistParameters,
|
||||||
songList: songListParameters,
|
songList: songListParameters,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { QueryFunctionContext } from '@tanstack/react-query';
|
||||||
|
import { LyricSource } from './types';
|
||||||
import type {
|
import type {
|
||||||
AlbumListQuery,
|
AlbumListQuery,
|
||||||
SongListQuery,
|
SongListQuery,
|
||||||
@@ -10,16 +12,57 @@ import type {
|
|||||||
UserListQuery,
|
UserListQuery,
|
||||||
AlbumArtistDetailQuery,
|
AlbumArtistDetailQuery,
|
||||||
TopSongListQuery,
|
TopSongListQuery,
|
||||||
|
SearchQuery,
|
||||||
|
SongDetailQuery,
|
||||||
|
RandomSongListQuery,
|
||||||
|
LyricsQuery,
|
||||||
|
LyricSearchQuery,
|
||||||
|
GenreListQuery,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export const queryKeys = {
|
export const splitPaginatedQuery = (key: any) => {
|
||||||
|
const { startIndex, limit, ...filter } = key || {};
|
||||||
|
|
||||||
|
if (startIndex !== undefined || limit !== undefined) {
|
||||||
|
return {
|
||||||
|
filter,
|
||||||
|
pagination: {
|
||||||
|
limit,
|
||||||
|
startIndex,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
filter,
|
||||||
|
pagination: undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QueryPagination = {
|
||||||
|
limit?: number;
|
||||||
|
startIndex?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const queryKeys: Record<
|
||||||
|
string,
|
||||||
|
Record<string, (...props: any) => QueryFunctionContext['queryKey']>
|
||||||
|
> = {
|
||||||
albumArtists: {
|
albumArtists: {
|
||||||
detail: (serverId: string, query?: AlbumArtistDetailQuery) => {
|
detail: (serverId: string, query?: AlbumArtistDetailQuery) => {
|
||||||
if (query) return [serverId, 'albumArtists', 'detail', query] as const;
|
if (query) return [serverId, 'albumArtists', 'detail', query] as const;
|
||||||
return [serverId, 'albumArtists', 'detail'] as const;
|
return [serverId, 'albumArtists', 'detail'] as const;
|
||||||
},
|
},
|
||||||
list: (serverId: string, query?: AlbumArtistListQuery) => {
|
list: (serverId: string, query?: AlbumArtistListQuery) => {
|
||||||
if (query) return [serverId, 'albumArtists', 'list', query] as const;
|
const { pagination, filter } = splitPaginatedQuery(query);
|
||||||
|
if (query && pagination) {
|
||||||
|
return [serverId, 'albumArtists', 'list', filter, pagination] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
return [serverId, 'albumArtists', 'list', filter] as const;
|
||||||
|
}
|
||||||
|
|
||||||
return [serverId, 'albumArtists', 'list'] as const;
|
return [serverId, 'albumArtists', 'list'] as const;
|
||||||
},
|
},
|
||||||
root: (serverId: string) => [serverId, 'albumArtists'] as const,
|
root: (serverId: string) => [serverId, 'albumArtists'] as const,
|
||||||
@@ -31,10 +74,34 @@ export const queryKeys = {
|
|||||||
albums: {
|
albums: {
|
||||||
detail: (serverId: string, query?: AlbumDetailQuery) =>
|
detail: (serverId: string, query?: AlbumDetailQuery) =>
|
||||||
[serverId, 'albums', 'detail', query] as const,
|
[serverId, 'albums', 'detail', query] as const,
|
||||||
list: (serverId: string, query?: AlbumListQuery) => {
|
list: (serverId: string, query?: AlbumListQuery, artistId?: string) => {
|
||||||
if (query) return [serverId, 'albums', 'list', query] as const;
|
const { pagination, filter } = splitPaginatedQuery(query);
|
||||||
|
|
||||||
|
if (query && pagination && artistId) {
|
||||||
|
return [serverId, 'albums', 'list', artistId, filter, pagination] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query && pagination) {
|
||||||
|
return [serverId, 'albums', 'list', filter, pagination] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query && artistId) {
|
||||||
|
return [serverId, 'albums', 'list', artistId, filter] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
return [serverId, 'albums', 'list', filter] as const;
|
||||||
|
}
|
||||||
|
|
||||||
return [serverId, 'albums', 'list'] as const;
|
return [serverId, 'albums', 'list'] as const;
|
||||||
},
|
},
|
||||||
|
related: (serverId: string, id: string, query?: AlbumDetailQuery) => {
|
||||||
|
if (query) {
|
||||||
|
return [serverId, 'albums', id, 'related', query] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [serverId, 'albums', id, 'related'] as const;
|
||||||
|
},
|
||||||
root: (serverId: string) => [serverId, 'albums'],
|
root: (serverId: string) => [serverId, 'albums'],
|
||||||
serverRoot: (serverId: string) => [serverId, 'albums'],
|
serverRoot: (serverId: string) => [serverId, 'albums'],
|
||||||
songs: (serverId: string, query: SongListQuery) =>
|
songs: (serverId: string, query: SongListQuery) =>
|
||||||
@@ -42,13 +109,32 @@ export const queryKeys = {
|
|||||||
},
|
},
|
||||||
artists: {
|
artists: {
|
||||||
list: (serverId: string, query?: ArtistListQuery) => {
|
list: (serverId: string, query?: ArtistListQuery) => {
|
||||||
if (query) return [serverId, 'artists', 'list', query] as const;
|
const { pagination, filter } = splitPaginatedQuery(query);
|
||||||
|
if (query && pagination) {
|
||||||
|
return [serverId, 'artists', 'list', filter, pagination] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
return [serverId, 'artists', 'list', filter] as const;
|
||||||
|
}
|
||||||
|
|
||||||
return [serverId, 'artists', 'list'] as const;
|
return [serverId, 'artists', 'list'] as const;
|
||||||
},
|
},
|
||||||
root: (serverId: string) => [serverId, 'artists'] as const,
|
root: (serverId: string) => [serverId, 'artists'] as const,
|
||||||
},
|
},
|
||||||
genres: {
|
genres: {
|
||||||
list: (serverId: string) => [serverId, 'genres', 'list'] as const,
|
list: (serverId: string, query?: GenreListQuery) => {
|
||||||
|
const { pagination, filter } = splitPaginatedQuery(query);
|
||||||
|
if (query && pagination) {
|
||||||
|
return [serverId, 'genres', 'list', filter, pagination] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
return [serverId, 'genres', 'list', filter] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [serverId, 'genres', 'list'] as const;
|
||||||
|
},
|
||||||
root: (serverId: string) => [serverId, 'genres'] as const,
|
root: (serverId: string) => [serverId, 'genres'] as const,
|
||||||
},
|
},
|
||||||
musicFolders: {
|
musicFolders: {
|
||||||
@@ -56,34 +142,102 @@ export const queryKeys = {
|
|||||||
},
|
},
|
||||||
playlists: {
|
playlists: {
|
||||||
detail: (serverId: string, id?: string, query?: PlaylistDetailQuery) => {
|
detail: (serverId: string, id?: string, query?: PlaylistDetailQuery) => {
|
||||||
if (query) return [serverId, 'playlists', id, 'detail', query] as const;
|
const { pagination, filter } = splitPaginatedQuery(query);
|
||||||
|
if (query && pagination) {
|
||||||
|
return [serverId, 'playlists', id, 'detail', filter, pagination] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
return [serverId, 'playlists', id, 'detail', filter] as const;
|
||||||
|
}
|
||||||
|
|
||||||
if (id) return [serverId, 'playlists', id, 'detail'] as const;
|
if (id) return [serverId, 'playlists', id, 'detail'] as const;
|
||||||
return [serverId, 'playlists', 'detail'] as const;
|
return [serverId, 'playlists', 'detail'] as const;
|
||||||
},
|
},
|
||||||
detailSongList: (serverId: string, id: string, query?: PlaylistSongListQuery) => {
|
detailSongList: (serverId: string, id: string, query?: PlaylistSongListQuery) => {
|
||||||
if (query) return [serverId, 'playlists', id, 'detailSongList', query] as const;
|
const { pagination, filter } = splitPaginatedQuery(query);
|
||||||
|
|
||||||
|
if (query && id && pagination) {
|
||||||
|
return [serverId, 'playlists', id, 'detailSongList', filter, pagination] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query && id) {
|
||||||
|
return [serverId, 'playlists', id, 'detailSongList', filter] as const;
|
||||||
|
}
|
||||||
|
|
||||||
if (id) return [serverId, 'playlists', id, 'detailSongList'] as const;
|
if (id) return [serverId, 'playlists', id, 'detailSongList'] as const;
|
||||||
|
|
||||||
return [serverId, 'playlists', 'detailSongList'] as const;
|
return [serverId, 'playlists', 'detailSongList'] as const;
|
||||||
},
|
},
|
||||||
list: (serverId: string, query?: PlaylistListQuery) => {
|
list: (serverId: string, query?: PlaylistListQuery) => {
|
||||||
if (query) return [serverId, 'playlists', 'list', query] as const;
|
const { pagination, filter } = splitPaginatedQuery(query);
|
||||||
|
if (query && pagination) {
|
||||||
|
return [serverId, 'playlists', 'list', filter, pagination] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
return [serverId, 'playlists', 'list', filter] as const;
|
||||||
|
}
|
||||||
|
|
||||||
return [serverId, 'playlists', 'list'] as const;
|
return [serverId, 'playlists', 'list'] as const;
|
||||||
},
|
},
|
||||||
root: (serverId: string) => [serverId, 'playlists'] as const,
|
root: (serverId: string) => [serverId, 'playlists'] as const,
|
||||||
songList: (serverId: string, id: string, query?: PlaylistSongListQuery) => {
|
songList: (serverId: string, id?: string, query?: PlaylistSongListQuery) => {
|
||||||
if (query) return [serverId, 'playlists', id, 'songList', query] as const;
|
const { pagination, filter } = splitPaginatedQuery(query);
|
||||||
|
if (query && id && pagination) {
|
||||||
|
return [serverId, 'playlists', id, 'songList', filter, pagination] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query && id) {
|
||||||
|
return [serverId, 'playlists', id, 'songList', filter] as const;
|
||||||
|
}
|
||||||
|
|
||||||
if (id) return [serverId, 'playlists', id, 'songList'] as const;
|
if (id) return [serverId, 'playlists', id, 'songList'] as const;
|
||||||
return [serverId, 'playlists', 'songList'] as const;
|
return [serverId, 'playlists', 'songList'] as const;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
search: {
|
||||||
|
list: (serverId: string, query?: SearchQuery) => {
|
||||||
|
if (query) return [serverId, 'search', 'list', query] as const;
|
||||||
|
return [serverId, 'search', 'list'] as const;
|
||||||
|
},
|
||||||
|
root: (serverId: string) => [serverId, 'search'] as const,
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
root: (serverId: string) => [serverId] as const,
|
root: (serverId: string) => [serverId] as const,
|
||||||
},
|
},
|
||||||
songs: {
|
songs: {
|
||||||
|
detail: (serverId: string, query?: SongDetailQuery) => {
|
||||||
|
if (query) return [serverId, 'songs', 'detail', query] as const;
|
||||||
|
return [serverId, 'songs', 'detail'] as const;
|
||||||
|
},
|
||||||
list: (serverId: string, query?: SongListQuery) => {
|
list: (serverId: string, query?: SongListQuery) => {
|
||||||
if (query) return [serverId, 'songs', 'list', query] as const;
|
const { pagination, filter } = splitPaginatedQuery(query);
|
||||||
|
if (query && pagination) {
|
||||||
|
return [serverId, 'songs', 'list', filter, pagination] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
return [serverId, 'songs', 'list', filter] as const;
|
||||||
|
}
|
||||||
|
|
||||||
return [serverId, 'songs', 'list'] as const;
|
return [serverId, 'songs', 'list'] as const;
|
||||||
},
|
},
|
||||||
|
lyrics: (serverId: string, query?: LyricsQuery) => {
|
||||||
|
if (query) return [serverId, 'song', 'lyrics', 'select', query] as const;
|
||||||
|
return [serverId, 'song', 'lyrics'] as const;
|
||||||
|
},
|
||||||
|
lyricsByRemoteId: (searchQuery: { remoteSongId: string; remoteSource: LyricSource }) => {
|
||||||
|
return ['song', 'lyrics', 'remote', searchQuery] as const;
|
||||||
|
},
|
||||||
|
lyricsSearch: (query?: LyricSearchQuery) => {
|
||||||
|
if (query) return ['lyrics', 'search', query] as const;
|
||||||
|
return ['lyrics', 'search'] as const;
|
||||||
|
},
|
||||||
|
randomSongList: (serverId: string, query?: RandomSongListQuery) => {
|
||||||
|
if (query) return [serverId, 'songs', 'randomSongList', query] as const;
|
||||||
|
return [serverId, 'songs', 'randomSongList'] as const;
|
||||||
|
},
|
||||||
root: (serverId: string) => [serverId, 'songs'] as const,
|
root: (serverId: string) => [serverId, 'songs'] as const,
|
||||||
},
|
},
|
||||||
users: {
|
users: {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { initClient, initContract } from '@ts-rest/core';
|
import { initClient, initContract } from '@ts-rest/core';
|
||||||
import axios, { Method, AxiosError, isAxiosError, AxiosResponse } from 'axios';
|
import axios, { Method, AxiosError, isAxiosError, AxiosResponse } from 'axios';
|
||||||
|
import omitBy from 'lodash/omitBy';
|
||||||
import qs from 'qs';
|
import qs from 'qs';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
|
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
|
||||||
@@ -40,6 +41,14 @@ export const contract = c.router({
|
|||||||
200: ssType._response.musicFolderList,
|
200: ssType._response.musicFolderList,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
getRandomSongList: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'getRandomSongs.view',
|
||||||
|
query: ssType._parameters.randomSongList,
|
||||||
|
responses: {
|
||||||
|
200: ssType._response.randomSongList,
|
||||||
|
},
|
||||||
|
},
|
||||||
getTopSongsList: {
|
getTopSongsList: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: 'getTopSongs.view',
|
path: 'getTopSongs.view',
|
||||||
@@ -64,6 +73,14 @@ export const contract = c.router({
|
|||||||
200: ssType._response.scrobble,
|
200: ssType._response.scrobble,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
search3: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'search3.view',
|
||||||
|
query: ssType._parameters.search3,
|
||||||
|
responses: {
|
||||||
|
200: ssType._response.search3,
|
||||||
|
},
|
||||||
|
},
|
||||||
setRating: {
|
setRating: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: 'setRating.view',
|
path: 'setRating.view',
|
||||||
@@ -101,6 +118,18 @@ axiosClient.interceptors.response.use(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const parsePath = (fullPath: string) => {
|
||||||
|
const [path, params] = fullPath.split('?');
|
||||||
|
|
||||||
|
const parsedParams = qs.parse(params);
|
||||||
|
const notNilParams = omitBy(parsedParams, (value) => value === 'undefined' || value === 'null');
|
||||||
|
|
||||||
|
return {
|
||||||
|
params: notNilParams,
|
||||||
|
path,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const ssApiClient = (args: {
|
export const ssApiClient = (args: {
|
||||||
server: ServerListItem | null;
|
server: ServerListItem | null;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
@@ -113,6 +142,8 @@ export const ssApiClient = (args: {
|
|||||||
let baseUrl: string | undefined;
|
let baseUrl: string | undefined;
|
||||||
const authParams: Record<string, any> = {};
|
const authParams: Record<string, any> = {};
|
||||||
|
|
||||||
|
const { params, path: api } = parsePath(path);
|
||||||
|
|
||||||
if (server) {
|
if (server) {
|
||||||
baseUrl = `${server.url}/rest`;
|
baseUrl = `${server.url}/rest`;
|
||||||
const token = server.credential;
|
const token = server.credential;
|
||||||
@@ -130,7 +161,9 @@ export const ssApiClient = (args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await axiosClient.request<z.infer<typeof ssType._response.baseResponse>>({
|
const result = await axiosClient.request<
|
||||||
|
z.infer<typeof ssType._response.baseResponse>
|
||||||
|
>({
|
||||||
data: body,
|
data: body,
|
||||||
headers,
|
headers,
|
||||||
method: method as Method,
|
method: method as Method,
|
||||||
@@ -139,22 +172,28 @@ export const ssApiClient = (args: {
|
|||||||
f: 'json',
|
f: 'json',
|
||||||
v: '1.13.0',
|
v: '1.13.0',
|
||||||
...authParams,
|
...authParams,
|
||||||
|
...params,
|
||||||
},
|
},
|
||||||
signal,
|
signal,
|
||||||
url: `${baseUrl}/${path}`,
|
url: `${baseUrl}/${api}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
body: result.data['subsonic-response'],
|
body: result.data['subsonic-response'],
|
||||||
|
headers: result.headers as any,
|
||||||
status: result.status,
|
status: result.status,
|
||||||
};
|
};
|
||||||
} catch (e: Error | AxiosError | any) {
|
} catch (e: Error | AxiosError | any) {
|
||||||
|
console.log('CATCH ERR');
|
||||||
|
|
||||||
if (isAxiosError(e)) {
|
if (isAxiosError(e)) {
|
||||||
const error = e as AxiosError;
|
const error = e as AxiosError;
|
||||||
const response = error.response as AxiosResponse;
|
const response = error.response as AxiosResponse;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
body: response.data,
|
body: response?.data,
|
||||||
status: response.status,
|
headers: response.headers as any,
|
||||||
|
status: response?.status,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import {
|
|||||||
ScrobbleResponse,
|
ScrobbleResponse,
|
||||||
SongListResponse,
|
SongListResponse,
|
||||||
TopSongListArgs,
|
TopSongListArgs,
|
||||||
|
SearchArgs,
|
||||||
|
SearchResponse,
|
||||||
|
RandomSongListResponse,
|
||||||
|
RandomSongListArgs,
|
||||||
} from '/@/renderer/api/types';
|
} from '/@/renderer/api/types';
|
||||||
import { randomString } from '/@/renderer/utils';
|
import { randomString } from '/@/renderer/utils';
|
||||||
|
|
||||||
@@ -262,8 +266,9 @@ const getTopSongList = async (args: TopSongListArgs): Promise<SongListResponse>
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
items:
|
items:
|
||||||
res.body.topSongs?.song?.map((song) => ssNormalize.song(song, apiClientProps.server, '')) ||
|
res.body.topSongs?.song?.map((song) =>
|
||||||
[],
|
ssNormalize.song(song, apiClientProps.server, ''),
|
||||||
|
) || [],
|
||||||
startIndex: 0,
|
startIndex: 0,
|
||||||
totalRecordCount: res.body.topSongs?.song?.length || 0,
|
totalRecordCount: res.body.topSongs?.song?.length || 0,
|
||||||
};
|
};
|
||||||
@@ -305,13 +310,73 @@ const scrobble = async (args: ScrobbleArgs): Promise<ScrobbleResponse> => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const search3 = async (args: SearchArgs): Promise<SearchResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
const res = await ssApiClient(apiClientProps).search3({
|
||||||
|
query: {
|
||||||
|
albumCount: query.albumLimit,
|
||||||
|
albumOffset: query.albumStartIndex,
|
||||||
|
artistCount: query.albumArtistLimit,
|
||||||
|
artistOffset: query.albumArtistStartIndex,
|
||||||
|
query: query.query,
|
||||||
|
songCount: query.songLimit,
|
||||||
|
songOffset: query.songStartIndex,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to search');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
albumArtists: res.body.searchResult3?.artist?.map((artist) =>
|
||||||
|
ssNormalize.albumArtist(artist, apiClientProps.server),
|
||||||
|
),
|
||||||
|
albums: res.body.searchResult3?.album?.map((album) =>
|
||||||
|
ssNormalize.album(album, apiClientProps.server),
|
||||||
|
),
|
||||||
|
songs: res.body.searchResult3?.song?.map((song) =>
|
||||||
|
ssNormalize.song(song, apiClientProps.server, ''),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRandomSongList = async (args: RandomSongListArgs): Promise<RandomSongListResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
const res = await ssApiClient(apiClientProps).getRandomSongList({
|
||||||
|
query: {
|
||||||
|
fromYear: query.minYear,
|
||||||
|
genre: query.genre,
|
||||||
|
musicFolderId: query.musicFolderId,
|
||||||
|
size: query.limit,
|
||||||
|
toYear: query.maxYear,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get random songs');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: res.body.randomSongs?.song?.map((song) =>
|
||||||
|
ssNormalize.song(song, apiClientProps.server, ''),
|
||||||
|
),
|
||||||
|
startIndex: 0,
|
||||||
|
totalRecordCount: res.body.randomSongs?.song?.length || 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const ssController = {
|
export const ssController = {
|
||||||
authenticate,
|
authenticate,
|
||||||
createFavorite,
|
createFavorite,
|
||||||
getArtistInfo,
|
getArtistInfo,
|
||||||
getMusicFolderList,
|
getMusicFolderList,
|
||||||
|
getRandomSongList,
|
||||||
getTopSongList,
|
getTopSongList,
|
||||||
removeFavorite,
|
removeFavorite,
|
||||||
scrobble,
|
scrobble,
|
||||||
|
search3,
|
||||||
setRating,
|
setRating,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
|
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
|
||||||
import { QueueSong, LibraryItem } from '/@/renderer/api/types';
|
import { QueueSong, LibraryItem, AlbumArtist, Album } from '/@/renderer/api/types';
|
||||||
import { ServerListItem, ServerType } from '/@/renderer/types';
|
import { ServerListItem, ServerType } from '/@/renderer/types';
|
||||||
|
|
||||||
const getCoverArtUrl = (args: {
|
const getCoverArtUrl = (args: {
|
||||||
@@ -10,7 +10,7 @@ const getCoverArtUrl = (args: {
|
|||||||
credential: string | undefined;
|
credential: string | undefined;
|
||||||
size: number;
|
size: number;
|
||||||
}) => {
|
}) => {
|
||||||
const size = args.size ? args.size : 150;
|
const size = args.size ? args.size : 250;
|
||||||
|
|
||||||
if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) {
|
if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) {
|
||||||
return null;
|
return null;
|
||||||
@@ -67,11 +67,15 @@ const normalizeSong = (
|
|||||||
container: item.contentType,
|
container: item.contentType,
|
||||||
createdAt: item.created,
|
createdAt: item.created,
|
||||||
discNumber: item.discNumber || 1,
|
discNumber: item.discNumber || 1,
|
||||||
duration: item.duration || 0,
|
discSubtitle: null,
|
||||||
|
duration: item.duration ? item.duration * 1000 : 0,
|
||||||
|
gain: null,
|
||||||
genres: item.genre
|
genres: item.genre
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: item.genre,
|
id: item.genre,
|
||||||
|
imageUrl: null,
|
||||||
|
itemType: LibraryItem.GENRE,
|
||||||
name: item.genre,
|
name: item.genre,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -81,8 +85,10 @@ const normalizeSong = (
|
|||||||
imageUrl,
|
imageUrl,
|
||||||
itemType: LibraryItem.SONG,
|
itemType: LibraryItem.SONG,
|
||||||
lastPlayedAt: null,
|
lastPlayedAt: null,
|
||||||
|
lyrics: null,
|
||||||
name: item.title,
|
name: item.title,
|
||||||
path: item.path,
|
path: item.path,
|
||||||
|
peak: null,
|
||||||
playCount: item?.playCount || 0,
|
playCount: item?.playCount || 0,
|
||||||
releaseDate: null,
|
releaseDate: null,
|
||||||
releaseYear: item.year ? String(item.year) : null,
|
releaseYear: item.year ? String(item.year) : null,
|
||||||
@@ -98,6 +104,93 @@ const normalizeSong = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeAlbumArtist = (
|
||||||
|
item: z.infer<typeof ssType._response.albumArtist>,
|
||||||
|
server: ServerListItem | null,
|
||||||
|
): AlbumArtist => {
|
||||||
|
const imageUrl =
|
||||||
|
getCoverArtUrl({
|
||||||
|
baseUrl: server?.url,
|
||||||
|
coverArtId: item.coverArt,
|
||||||
|
credential: server?.credential,
|
||||||
|
size: 100,
|
||||||
|
}) || null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
albumCount: item.albumCount ? Number(item.albumCount) : 0,
|
||||||
|
backgroundImageUrl: null,
|
||||||
|
biography: null,
|
||||||
|
duration: null,
|
||||||
|
genres: [],
|
||||||
|
id: item.id,
|
||||||
|
imageUrl,
|
||||||
|
itemType: LibraryItem.ALBUM_ARTIST,
|
||||||
|
lastPlayedAt: null,
|
||||||
|
name: item.name,
|
||||||
|
playCount: null,
|
||||||
|
serverId: server?.id || 'unknown',
|
||||||
|
serverType: ServerType.SUBSONIC,
|
||||||
|
similarArtists: [],
|
||||||
|
songCount: null,
|
||||||
|
userFavorite: false,
|
||||||
|
userRating: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeAlbum = (
|
||||||
|
item: z.infer<typeof ssType._response.album>,
|
||||||
|
server: ServerListItem | null,
|
||||||
|
): Album => {
|
||||||
|
const imageUrl =
|
||||||
|
getCoverArtUrl({
|
||||||
|
baseUrl: server?.url,
|
||||||
|
coverArtId: item.coverArt,
|
||||||
|
credential: server?.credential,
|
||||||
|
size: 300,
|
||||||
|
}) || null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
albumArtists: item.artistId
|
||||||
|
? [{ id: item.artistId, imageUrl: null, name: item.artist }]
|
||||||
|
: [],
|
||||||
|
artists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [],
|
||||||
|
backdropImageUrl: null,
|
||||||
|
createdAt: item.created,
|
||||||
|
duration: item.duration,
|
||||||
|
genres: item.genre
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: item.genre,
|
||||||
|
imageUrl: null,
|
||||||
|
itemType: LibraryItem.GENRE,
|
||||||
|
name: item.genre,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
id: item.id,
|
||||||
|
imagePlaceholderUrl: null,
|
||||||
|
imageUrl,
|
||||||
|
isCompilation: null,
|
||||||
|
itemType: LibraryItem.ALBUM,
|
||||||
|
lastPlayedAt: null,
|
||||||
|
name: item.name,
|
||||||
|
playCount: null,
|
||||||
|
releaseDate: item.year ? new Date(item.year, 0, 1).toISOString() : null,
|
||||||
|
releaseYear: item.year ? Number(item.year) : null,
|
||||||
|
serverId: server?.id || 'unknown',
|
||||||
|
serverType: ServerType.SUBSONIC,
|
||||||
|
size: null,
|
||||||
|
songCount: item.songCount,
|
||||||
|
songs: [],
|
||||||
|
uniqueId: nanoid(),
|
||||||
|
updatedAt: item.created,
|
||||||
|
userFavorite: item.starred || false,
|
||||||
|
userRating: item.userRating || null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const ssNormalize = {
|
export const ssNormalize = {
|
||||||
|
album: normalizeAlbum,
|
||||||
|
albumArtist: normalizeAlbumArtist,
|
||||||
song: normalizeSong,
|
song: normalizeSong,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ const artistInfoParameters = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const artistInfo = z.object({
|
const artistInfo = z.object({
|
||||||
artistInfo2: z.object({
|
artistInfo: z.object({
|
||||||
biography: z.string().optional(),
|
biography: z.string().optional(),
|
||||||
largeImageUrl: z.string().optional(),
|
largeImageUrl: z.string().optional(),
|
||||||
lastFmUrl: z.string().optional(),
|
lastFmUrl: z.string().optional(),
|
||||||
@@ -173,18 +173,55 @@ const scrobbleParameters = z.object({
|
|||||||
|
|
||||||
const scrobble = z.null();
|
const scrobble = z.null();
|
||||||
|
|
||||||
|
const search3 = z.object({
|
||||||
|
searchResult3: z.object({
|
||||||
|
album: z.array(album),
|
||||||
|
artist: z.array(albumArtist),
|
||||||
|
song: z.array(song),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const search3Parameters = z.object({
|
||||||
|
albumCount: z.number().optional(),
|
||||||
|
albumOffset: z.number().optional(),
|
||||||
|
artistCount: z.number().optional(),
|
||||||
|
artistOffset: z.number().optional(),
|
||||||
|
musicFolderId: z.string().optional(),
|
||||||
|
query: z.string().optional(),
|
||||||
|
songCount: z.number().optional(),
|
||||||
|
songOffset: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const randomSongListParameters = z.object({
|
||||||
|
fromYear: z.number().optional(),
|
||||||
|
genre: z.string().optional(),
|
||||||
|
musicFolderId: z.string().optional(),
|
||||||
|
size: z.number().optional(),
|
||||||
|
toYear: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const randomSongList = z.object({
|
||||||
|
randomSongs: z.object({
|
||||||
|
song: z.array(song),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
export const ssType = {
|
export const ssType = {
|
||||||
_parameters: {
|
_parameters: {
|
||||||
albumList: albumListParameters,
|
albumList: albumListParameters,
|
||||||
artistInfo: artistInfoParameters,
|
artistInfo: artistInfoParameters,
|
||||||
authenticate: authenticateParameters,
|
authenticate: authenticateParameters,
|
||||||
createFavorite: createFavoriteParameters,
|
createFavorite: createFavoriteParameters,
|
||||||
|
randomSongList: randomSongListParameters,
|
||||||
removeFavorite: removeFavoriteParameters,
|
removeFavorite: removeFavoriteParameters,
|
||||||
scrobble: scrobbleParameters,
|
scrobble: scrobbleParameters,
|
||||||
|
search3: search3Parameters,
|
||||||
setRating: setRatingParameters,
|
setRating: setRatingParameters,
|
||||||
topSongsList: topSongsListParameters,
|
topSongsList: topSongsListParameters,
|
||||||
},
|
},
|
||||||
_response: {
|
_response: {
|
||||||
|
album,
|
||||||
|
albumArtist,
|
||||||
albumArtistList,
|
albumArtistList,
|
||||||
albumList,
|
albumList,
|
||||||
artistInfo,
|
artistInfo,
|
||||||
@@ -192,8 +229,10 @@ export const ssType = {
|
|||||||
baseResponse,
|
baseResponse,
|
||||||
createFavorite,
|
createFavorite,
|
||||||
musicFolderList,
|
musicFolderList,
|
||||||
|
randomSongList,
|
||||||
removeFavorite,
|
removeFavorite,
|
||||||
scrobble,
|
scrobble,
|
||||||
|
search3,
|
||||||
setRating,
|
setRating,
|
||||||
song,
|
song,
|
||||||
topSongsList,
|
topSongsList,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { jfType } from './jellyfin/jellyfin-types';
|
||||||
import {
|
import {
|
||||||
JFSortOrder,
|
JFSortOrder,
|
||||||
JFAlbumListSort,
|
JFAlbumListSort,
|
||||||
@@ -5,7 +7,9 @@ import {
|
|||||||
JFAlbumArtistListSort,
|
JFAlbumArtistListSort,
|
||||||
JFArtistListSort,
|
JFArtistListSort,
|
||||||
JFPlaylistListSort,
|
JFPlaylistListSort,
|
||||||
} from '/@/renderer/api/jellyfin.types';
|
JFGenreListSort,
|
||||||
|
} from './jellyfin.types';
|
||||||
|
import { ndType } from './navidrome/navidrome-types';
|
||||||
import {
|
import {
|
||||||
NDSortOrder,
|
NDSortOrder,
|
||||||
NDOrder,
|
NDOrder,
|
||||||
@@ -14,12 +18,14 @@ import {
|
|||||||
NDPlaylistListSort,
|
NDPlaylistListSort,
|
||||||
NDSongListSort,
|
NDSongListSort,
|
||||||
NDUserListSort,
|
NDUserListSort,
|
||||||
} from '/@/renderer/api/navidrome.types';
|
NDGenreListSort,
|
||||||
|
} from './navidrome.types';
|
||||||
|
|
||||||
export enum LibraryItem {
|
export enum LibraryItem {
|
||||||
ALBUM = 'album',
|
ALBUM = 'album',
|
||||||
ALBUM_ARTIST = 'albumArtist',
|
ALBUM_ARTIST = 'albumArtist',
|
||||||
ARTIST = 'artist',
|
ARTIST = 'artist',
|
||||||
|
GENRE = 'genre',
|
||||||
PLAYLIST = 'playlist',
|
PLAYLIST = 'playlist',
|
||||||
SONG = 'song',
|
SONG = 'song',
|
||||||
}
|
}
|
||||||
@@ -131,6 +137,8 @@ export type AuthenticationResponse = {
|
|||||||
export type Genre = {
|
export type Genre = {
|
||||||
albumCount?: number;
|
albumCount?: number;
|
||||||
id: string;
|
id: string;
|
||||||
|
imageUrl: string | null;
|
||||||
|
itemType: LibraryItem.GENRE;
|
||||||
name: string;
|
name: string;
|
||||||
songCount?: number;
|
songCount?: number;
|
||||||
};
|
};
|
||||||
@@ -163,8 +171,13 @@ export type Album = {
|
|||||||
userRating: number | null;
|
userRating: number | null;
|
||||||
} & { songs?: Song[] };
|
} & { songs?: Song[] };
|
||||||
|
|
||||||
|
export type GainInfo = {
|
||||||
|
album?: number;
|
||||||
|
track?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type Song = {
|
export type Song = {
|
||||||
album: string;
|
album: string | null;
|
||||||
albumArtists: RelatedArtist[];
|
albumArtists: RelatedArtist[];
|
||||||
albumId: string;
|
albumId: string;
|
||||||
artistName: string;
|
artistName: string;
|
||||||
@@ -177,15 +190,19 @@ export type Song = {
|
|||||||
container: string | null;
|
container: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
discNumber: number;
|
discNumber: number;
|
||||||
|
discSubtitle: string | null;
|
||||||
duration: number;
|
duration: number;
|
||||||
|
gain: GainInfo | null;
|
||||||
genres: Genre[];
|
genres: Genre[];
|
||||||
id: string;
|
id: string;
|
||||||
imagePlaceholderUrl: string | null;
|
imagePlaceholderUrl: string | null;
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
itemType: LibraryItem.SONG;
|
itemType: LibraryItem.SONG;
|
||||||
lastPlayedAt: string | null;
|
lastPlayedAt: string | null;
|
||||||
|
lyrics: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
path: string | null;
|
path: string | null;
|
||||||
|
peak: GainInfo | null;
|
||||||
playCount: number;
|
playCount: number;
|
||||||
playlistItemId?: string;
|
playlistItemId?: string;
|
||||||
releaseDate: string | null;
|
releaseDate: string | null;
|
||||||
@@ -288,7 +305,40 @@ export type GenreListResponse = BasePaginatedResponse<Genre[]> | null | undefine
|
|||||||
|
|
||||||
export type GenreListArgs = { query: GenreListQuery } & BaseEndpointArgs;
|
export type GenreListArgs = { query: GenreListQuery } & BaseEndpointArgs;
|
||||||
|
|
||||||
export type GenreListQuery = null;
|
export enum GenreListSort {
|
||||||
|
NAME = 'name',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GenreListQuery = {
|
||||||
|
_custom?: {
|
||||||
|
jellyfin?: null;
|
||||||
|
navidrome?: null;
|
||||||
|
};
|
||||||
|
limit?: number;
|
||||||
|
musicFolderId?: string;
|
||||||
|
searchTerm?: string;
|
||||||
|
sortBy: GenreListSort;
|
||||||
|
sortOrder: SortOrder;
|
||||||
|
startIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GenreListSortMap = {
|
||||||
|
jellyfin: Record<GenreListSort, JFGenreListSort | undefined>;
|
||||||
|
navidrome: Record<GenreListSort, NDGenreListSort | undefined>;
|
||||||
|
subsonic: Record<UserListSort, undefined>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const genreListSortMap: GenreListSortMap = {
|
||||||
|
jellyfin: {
|
||||||
|
name: JFGenreListSort.NAME,
|
||||||
|
},
|
||||||
|
navidrome: {
|
||||||
|
name: NDGenreListSort.NAME,
|
||||||
|
},
|
||||||
|
subsonic: {
|
||||||
|
name: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Album List
|
// Album List
|
||||||
export type AlbumListResponse = BasePaginatedResponse<Album[]> | null | undefined;
|
export type AlbumListResponse = BasePaginatedResponse<Album[]> | null | undefined;
|
||||||
@@ -313,28 +363,11 @@ export enum AlbumListSort {
|
|||||||
|
|
||||||
export type AlbumListQuery = {
|
export type AlbumListQuery = {
|
||||||
_custom?: {
|
_custom?: {
|
||||||
jellyfin?: {
|
jellyfin?: Partial<z.infer<typeof jfType._parameters.albumList>> & {
|
||||||
albumArtistIds?: string;
|
maxYear?: number;
|
||||||
artistIds?: string;
|
minYear?: number;
|
||||||
contributingArtistIds?: string;
|
|
||||||
filters?: string;
|
|
||||||
genreIds?: string;
|
|
||||||
genres?: string;
|
|
||||||
isFavorite?: boolean;
|
|
||||||
maxYear?: number; // Parses to years
|
|
||||||
minYear?: number; // Parses to years
|
|
||||||
tags?: string;
|
|
||||||
};
|
|
||||||
navidrome?: {
|
|
||||||
artist_id?: string;
|
|
||||||
compilation?: boolean;
|
|
||||||
genre_id?: string;
|
|
||||||
has_rating?: boolean;
|
|
||||||
name?: string;
|
|
||||||
recently_played?: boolean;
|
|
||||||
starred?: boolean;
|
|
||||||
year?: number;
|
|
||||||
};
|
};
|
||||||
|
navidrome?: Partial<z.infer<typeof ndType._parameters.albumList>>;
|
||||||
};
|
};
|
||||||
artistIds?: string[];
|
artistIds?: string[];
|
||||||
limit?: number;
|
limit?: number;
|
||||||
@@ -415,7 +448,7 @@ export type AlbumDetailQuery = { id: string };
|
|||||||
export type AlbumDetailArgs = { query: AlbumDetailQuery } & BaseEndpointArgs;
|
export type AlbumDetailArgs = { query: AlbumDetailQuery } & BaseEndpointArgs;
|
||||||
|
|
||||||
// Song List
|
// Song List
|
||||||
export type SongListResponse = BasePaginatedResponse<Song[]>;
|
export type SongListResponse = BasePaginatedResponse<Song[]> | null | undefined;
|
||||||
|
|
||||||
export enum SongListSort {
|
export enum SongListSort {
|
||||||
ALBUM = 'album',
|
ALBUM = 'album',
|
||||||
@@ -440,32 +473,15 @@ export enum SongListSort {
|
|||||||
|
|
||||||
export type SongListQuery = {
|
export type SongListQuery = {
|
||||||
_custom?: {
|
_custom?: {
|
||||||
jellyfin?: {
|
jellyfin?: Partial<z.infer<typeof jfType._parameters.songList>> & {
|
||||||
artistIds?: string;
|
maxYear?: number;
|
||||||
contributingArtistIds?: string;
|
minYear?: number;
|
||||||
filters?: string;
|
|
||||||
genreIds?: string;
|
|
||||||
genres?: string;
|
|
||||||
includeItemTypes: 'Audio';
|
|
||||||
isFavorite?: boolean;
|
|
||||||
maxYear?: number; // Parses to years
|
|
||||||
minYear?: number; // Parses to years
|
|
||||||
sortBy?: JFSongListSort;
|
|
||||||
years?: string;
|
|
||||||
};
|
|
||||||
navidrome?: {
|
|
||||||
album_id?: string[];
|
|
||||||
artist_id?: string[];
|
|
||||||
compilation?: boolean;
|
|
||||||
genre_id?: string;
|
|
||||||
has_rating?: boolean;
|
|
||||||
starred?: boolean;
|
|
||||||
title?: string;
|
|
||||||
year?: number;
|
|
||||||
};
|
};
|
||||||
|
navidrome?: Partial<z.infer<typeof ndType._parameters.songList>>;
|
||||||
};
|
};
|
||||||
albumIds?: string[];
|
albumIds?: string[];
|
||||||
artistIds?: string[];
|
artistIds?: string[];
|
||||||
|
imageSize?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
musicFolderId?: string;
|
musicFolderId?: string;
|
||||||
searchTerm?: string;
|
searchTerm?: string;
|
||||||
@@ -553,7 +569,7 @@ export type SongDetailQuery = { id: string };
|
|||||||
export type SongDetailArgs = { query: SongDetailQuery } & BaseEndpointArgs;
|
export type SongDetailArgs = { query: SongDetailQuery } & BaseEndpointArgs;
|
||||||
|
|
||||||
// Album Artist List
|
// Album Artist List
|
||||||
export type AlbumArtistListResponse = BasePaginatedResponse<AlbumArtist[]> | null;
|
export type AlbumArtistListResponse = BasePaginatedResponse<AlbumArtist[]> | null | undefined;
|
||||||
|
|
||||||
export enum AlbumArtistListSort {
|
export enum AlbumArtistListSort {
|
||||||
ALBUM = 'album',
|
ALBUM = 'album',
|
||||||
@@ -571,11 +587,8 @@ export enum AlbumArtistListSort {
|
|||||||
|
|
||||||
export type AlbumArtistListQuery = {
|
export type AlbumArtistListQuery = {
|
||||||
_custom?: {
|
_custom?: {
|
||||||
navidrome?: {
|
jellyfin?: Partial<z.infer<typeof jfType._parameters.albumArtistList>>;
|
||||||
genre_id?: string;
|
navidrome?: Partial<z.infer<typeof ndType._parameters.albumArtistList>>;
|
||||||
name?: string;
|
|
||||||
starred?: boolean;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
limit?: number;
|
limit?: number;
|
||||||
musicFolderId?: string;
|
musicFolderId?: string;
|
||||||
@@ -644,7 +657,7 @@ export type AlbumArtistDetailQuery = { id: string };
|
|||||||
export type AlbumArtistDetailArgs = { query: AlbumArtistDetailQuery } & BaseEndpointArgs;
|
export type AlbumArtistDetailArgs = { query: AlbumArtistDetailQuery } & BaseEndpointArgs;
|
||||||
|
|
||||||
// Artist List
|
// Artist List
|
||||||
export type ArtistListResponse = BasePaginatedResponse<Artist[]>;
|
export type ArtistListResponse = BasePaginatedResponse<Artist[]> | null | undefined;
|
||||||
|
|
||||||
export enum ArtistListSort {
|
export enum ArtistListSort {
|
||||||
ALBUM = 'album',
|
ALBUM = 'album',
|
||||||
@@ -662,11 +675,8 @@ export enum ArtistListSort {
|
|||||||
|
|
||||||
export type ArtistListQuery = {
|
export type ArtistListQuery = {
|
||||||
_custom?: {
|
_custom?: {
|
||||||
navidrome?: {
|
jellyfin?: Partial<z.infer<typeof jfType._parameters.albumArtistList>>;
|
||||||
genre_id?: string;
|
navidrome?: Partial<z.infer<typeof ndType._parameters.albumArtistList>>;
|
||||||
name?: string;
|
|
||||||
starred?: boolean;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
limit?: number;
|
limit?: number;
|
||||||
musicFolderId?: string;
|
musicFolderId?: string;
|
||||||
@@ -835,7 +845,7 @@ export type DeletePlaylistArgs = {
|
|||||||
} & BaseEndpointArgs;
|
} & BaseEndpointArgs;
|
||||||
|
|
||||||
// Playlist List
|
// Playlist List
|
||||||
export type PlaylistListResponse = BasePaginatedResponse<Playlist[]>;
|
export type PlaylistListResponse = BasePaginatedResponse<Playlist[]> | null | undefined;
|
||||||
|
|
||||||
export enum PlaylistListSort {
|
export enum PlaylistListSort {
|
||||||
DURATION = 'duration',
|
DURATION = 'duration',
|
||||||
@@ -848,10 +858,8 @@ export enum PlaylistListSort {
|
|||||||
|
|
||||||
export type PlaylistListQuery = {
|
export type PlaylistListQuery = {
|
||||||
_custom?: {
|
_custom?: {
|
||||||
navidrome?: {
|
jellyfin?: Partial<z.infer<typeof jfType._parameters.playlistList>>;
|
||||||
owner_id?: string;
|
navidrome?: Partial<z.infer<typeof ndType._parameters.playlistList>>;
|
||||||
smart?: boolean;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
limit?: number;
|
limit?: number;
|
||||||
searchTerm?: string;
|
searchTerm?: string;
|
||||||
@@ -905,7 +913,7 @@ export type PlaylistDetailQuery = {
|
|||||||
export type PlaylistDetailArgs = { query: PlaylistDetailQuery } & BaseEndpointArgs;
|
export type PlaylistDetailArgs = { query: PlaylistDetailQuery } & BaseEndpointArgs;
|
||||||
|
|
||||||
// Playlist Songs
|
// Playlist Songs
|
||||||
export type PlaylistSongListResponse = BasePaginatedResponse<Song[]>;
|
export type PlaylistSongListResponse = BasePaginatedResponse<Song[]> | null | undefined;
|
||||||
|
|
||||||
export type PlaylistSongListQuery = {
|
export type PlaylistSongListQuery = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -918,7 +926,7 @@ export type PlaylistSongListQuery = {
|
|||||||
export type PlaylistSongListArgs = { query: PlaylistSongListQuery } & BaseEndpointArgs;
|
export type PlaylistSongListArgs = { query: PlaylistSongListQuery } & BaseEndpointArgs;
|
||||||
|
|
||||||
// Music Folder List
|
// Music Folder List
|
||||||
export type MusicFolderListResponse = BasePaginatedResponse<MusicFolder[]>;
|
export type MusicFolderListResponse = BasePaginatedResponse<MusicFolder[]> | null | undefined;
|
||||||
|
|
||||||
export type MusicFolderListQuery = null;
|
export type MusicFolderListQuery = null;
|
||||||
|
|
||||||
@@ -926,7 +934,7 @@ export type MusicFolderListArgs = BaseEndpointArgs;
|
|||||||
|
|
||||||
// User list
|
// User list
|
||||||
// Playlist List
|
// Playlist List
|
||||||
export type UserListResponse = BasePaginatedResponse<User[]>;
|
export type UserListResponse = BasePaginatedResponse<User[]> | null | undefined;
|
||||||
|
|
||||||
export enum UserListSort {
|
export enum UserListSort {
|
||||||
NAME = 'name',
|
NAME = 'name',
|
||||||
@@ -966,7 +974,7 @@ export const userListSortMap: UserListSortMap = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Top Songs List
|
// Top Songs List
|
||||||
export type TopSongListResponse = BasePaginatedResponse<Song[]>;
|
export type TopSongListResponse = BasePaginatedResponse<Song[]> | null | undefined;
|
||||||
|
|
||||||
export type TopSongListQuery = {
|
export type TopSongListQuery = {
|
||||||
artist: string;
|
artist: string;
|
||||||
@@ -999,3 +1007,126 @@ export type ScrobbleQuery = {
|
|||||||
position?: number;
|
position?: number;
|
||||||
submission: boolean;
|
submission: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SearchQuery = {
|
||||||
|
albumArtistLimit?: number;
|
||||||
|
albumArtistStartIndex?: number;
|
||||||
|
albumLimit?: number;
|
||||||
|
albumStartIndex?: number;
|
||||||
|
musicFolderId?: string;
|
||||||
|
query?: string;
|
||||||
|
songLimit?: number;
|
||||||
|
songStartIndex?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchSongsQuery = {
|
||||||
|
musicFolderId?: string;
|
||||||
|
query?: string;
|
||||||
|
songLimit?: number;
|
||||||
|
songStartIndex?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchAlbumsQuery = {
|
||||||
|
albumLimit?: number;
|
||||||
|
albumStartIndex?: number;
|
||||||
|
musicFolderId?: string;
|
||||||
|
query?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchAlbumArtistsQuery = {
|
||||||
|
albumArtistLimit?: number;
|
||||||
|
albumArtistStartIndex?: number;
|
||||||
|
musicFolderId?: string;
|
||||||
|
query?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchArgs = {
|
||||||
|
query: SearchQuery;
|
||||||
|
} & BaseEndpointArgs;
|
||||||
|
|
||||||
|
export type SearchResponse = {
|
||||||
|
albumArtists: AlbumArtist[];
|
||||||
|
albums: Album[];
|
||||||
|
songs: Song[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RandomSongListQuery = {
|
||||||
|
genre?: string;
|
||||||
|
limit?: number;
|
||||||
|
maxYear?: number;
|
||||||
|
minYear?: number;
|
||||||
|
musicFolderId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RandomSongListArgs = {
|
||||||
|
query: RandomSongListQuery;
|
||||||
|
} & BaseEndpointArgs;
|
||||||
|
|
||||||
|
export type RandomSongListResponse = SongListResponse;
|
||||||
|
|
||||||
|
export type LyricsQuery = {
|
||||||
|
songId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LyricsArgs = {
|
||||||
|
query: LyricsQuery;
|
||||||
|
} & BaseEndpointArgs;
|
||||||
|
|
||||||
|
export type SynchronizedLyricsArray = Array<[number, string]>;
|
||||||
|
|
||||||
|
export type LyricsResponse = SynchronizedLyricsArray | string;
|
||||||
|
|
||||||
|
export type InternetProviderLyricResponse = {
|
||||||
|
artist: string;
|
||||||
|
id: string;
|
||||||
|
lyrics: string;
|
||||||
|
name: string;
|
||||||
|
source: LyricSource;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InternetProviderLyricSearchResponse = {
|
||||||
|
artist: string;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
score?: number;
|
||||||
|
source: LyricSource;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SynchronizedLyricMetadata = {
|
||||||
|
lyrics: SynchronizedLyricsArray;
|
||||||
|
remote: boolean;
|
||||||
|
} & Omit<InternetProviderLyricResponse, 'lyrics'>;
|
||||||
|
|
||||||
|
export type UnsynchronizedLyricMetadata = {
|
||||||
|
lyrics: string;
|
||||||
|
remote: boolean;
|
||||||
|
} & Omit<InternetProviderLyricResponse, 'lyrics'>;
|
||||||
|
|
||||||
|
export type FullLyricsMetadata = SynchronizedLyricMetadata | UnsynchronizedLyricMetadata;
|
||||||
|
|
||||||
|
export type LyricOverride = Omit<InternetProviderLyricResponse, 'lyrics'>;
|
||||||
|
|
||||||
|
export const instanceOfCancellationError = (error: any) => {
|
||||||
|
return 'revert' in error;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LyricSearchQuery = {
|
||||||
|
album?: string;
|
||||||
|
artist?: string;
|
||||||
|
duration?: number;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LyricGetQuery = {
|
||||||
|
remoteSongId: string;
|
||||||
|
remoteSource: LyricSource;
|
||||||
|
song: Song;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum LyricSource {
|
||||||
|
GENIUS = 'Genius',
|
||||||
|
LRCLIB = 'lrclib.net',
|
||||||
|
NETEASE = 'NetEase',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LyricsOverride = Omit<FullLyricsMetadata, 'lyrics'> & { id: string };
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { AxiosHeaders } from 'axios';
|
import { AxiosHeaders } from 'axios';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { toast } from '/@/renderer/components';
|
||||||
|
import { useAuthStore } from '/@/renderer/store';
|
||||||
|
import { ServerListItem } from '/@/renderer/types';
|
||||||
|
|
||||||
// Since ts-rest client returns a strict response type, we need to add the headers to the body object
|
// Since ts-rest client returns a strict response type, we need to add the headers to the body object
|
||||||
export const resultWithHeaders = <ItemType extends z.ZodTypeAny>(itemSchema: ItemType) => {
|
export const resultWithHeaders = <ItemType extends z.ZodTypeAny>(itemSchema: ItemType) => {
|
||||||
@@ -21,3 +24,17 @@ export const resultSubsonicBaseResponse = <ItemType extends z.ZodRawShape>(
|
|||||||
.extend(itemSchema),
|
.extend(itemSchema),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const authenticationFailure = (currentServer: ServerListItem | null) => {
|
||||||
|
toast.error({
|
||||||
|
message: 'Your session has expired.',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentServer) {
|
||||||
|
const serverId = currentServer.id;
|
||||||
|
const token = currentServer.ndCredential;
|
||||||
|
console.log(`token is expired: ${token}`);
|
||||||
|
useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined });
|
||||||
|
useAuthStore.getState().actions.setCurrentServer(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||