restructure files onto electron-vite boilerplate

This commit is contained in:
jeffvli
2025-05-18 14:03:18 -07:00
parent 91ce2cd8a1
commit 1cf587bc8f
457 changed files with 9927 additions and 11705 deletions

View File

@@ -1,12 +1,9 @@
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
trim_trailing_whitespace = true

View File

@@ -1,7 +0,0 @@
{
"rules": {
"no-console": "off",
"global-require": "off",
"import/no-dynamic-require": "off"
}
}

View File

@@ -1,64 +0,0 @@
/**
* Base webpack config used across other specific configs
*/
import webpack from 'webpack';
import { dependencies as externals } from '../../release/app/package.json';
import webpackPaths from './webpack.paths';
import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin';
const createStyledComponentsTransformer = require('typescript-plugin-styled-components').default;
const styledComponentsTransformer = createStyledComponentsTransformer();
const configuration: webpack.Configuration = {
externals: [...Object.keys(externals || {})],
module: {
rules: [
{
exclude: /node_modules/,
test: /\.[jt]sx?$/,
use: {
loader: 'ts-loader',
options: {
// Remove this line to enable type checking in webpack builds
transpileOnly: true,
getCustomTransformers: () => ({ before: [styledComponentsTransformer] }),
},
},
},
],
},
output: {
// https://github.com/webpack/webpack/issues/1114
library: {
type: 'commonjs2',
},
path: webpackPaths.srcPath,
},
plugins: [
new webpack.EnvironmentPlugin({
NODE_ENV: 'production',
}),
],
/**
* Determine the array of extensions that should be used to resolve modules.
*/
resolve: {
extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'],
fallback: {
child_process: false,
},
plugins: [new TsconfigPathsPlugin({ baseUrl: webpackPaths.srcPath })],
modules: [webpackPaths.srcPath, 'node_modules'],
},
stats: 'errors-only',
};
export default configuration;

View File

@@ -1,3 +0,0 @@
/* eslint import/no-unresolved: off, import/no-self-import: off */
module.exports = require('./webpack.config.renderer.dev').default;

View File

@@ -1,84 +0,0 @@
/**
* Webpack config for production electron main process
*/
import path from 'path';
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: 'electron-main',
entry: {
main: path.join(webpackPaths.srcMainPath, 'main.ts'),
preload: path.join(webpackPaths.srcMainPath, 'preload.ts'),
},
output: {
path: webpackPaths.distMainPath,
filename: '[name].js',
},
optimization: {
minimizer: [
new TerserPlugin({
parallel: true,
}),
],
},
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
}),
/**
* 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,
START_MINIMIZED: false,
}),
],
/**
* Disables webpack processing of __dirname and __filename.
* If you run the bundle in node.js it falls back to these values of node.js.
* https://github.com/webpack/webpack/issues/2010
*/
node: {
__dirname: false,
__filename: false,
},
};
export default merge(baseConfig, configuration);

View File

@@ -1,70 +0,0 @@
import path from 'path';
import webpack from 'webpack';
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
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 configuration: webpack.Configuration = {
devtool: 'inline-source-map',
mode: 'development',
target: 'electron-preload',
entry: path.join(webpackPaths.srcMainPath, 'preload.ts'),
output: {
path: webpackPaths.dllPath,
filename: 'preload.js',
},
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
}),
/**
* 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,
}),
],
/**
* Disables webpack processing of __dirname and __filename.
* If you run the bundle in node.js it falls back to these values of node.js.
* https://github.com/webpack/webpack/issues/2010
*/
node: {
__dirname: false,
__filename: false,
},
watch: true,
};
export default merge(baseConfig, configuration);

View File

@@ -1,127 +0,0 @@
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';
const { version } = require('../../package.json');
// 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 configuration: webpack.Configuration = {
devtool: 'inline-source-map',
mode: 'development',
target: ['web'],
entry: {
remote: path.join(webpackPaths.srcRemotePath, 'index.tsx'),
worker: path.join(webpackPaths.srcRemotePath, 'service-worker.ts'),
},
output: {
path: webpackPaths.dllPath,
publicPath: '/',
filename: '[name].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,
templateParameters: {
version,
prod: false,
},
}),
],
node: {
__dirname: false,
__filename: false,
},
watch: true,
};
export default merge(baseConfig, configuration);

View File

@@ -1,142 +0,0 @@
/**
* 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';
const { version } = require('../../package.json');
checkNodeEnv('production');
deleteSourceMaps();
const devtoolsConfig =
process.env.DEBUG_PROD === 'true'
? {
devtool: 'source-map',
}
: {};
const configuration: webpack.Configuration = {
...devtoolsConfig,
mode: 'production',
target: ['web'],
entry: {
remote: path.join(webpackPaths.srcRemotePath, 'index.tsx'),
worker: path.join(webpackPaths.srcRemotePath, 'service-worker.ts'),
},
output: {
path: webpackPaths.distRemotePath,
publicPath: './',
filename: '[name].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.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',
templateParameters: {
version,
prod: true,
},
}),
],
};
export default merge(baseConfig, configuration);

View File

@@ -1,79 +0,0 @@
/**
* Builds the DLL for development electron renderer process
*/
import path from 'path';
import webpack from 'webpack';
import { merge } from 'webpack-merge';
import { dependencies } from '../../package.json';
import checkNodeEnv from '../scripts/check-node-env';
import baseConfig from './webpack.config.base';
import webpackPaths from './webpack.paths';
checkNodeEnv('development');
const dist = webpackPaths.dllPath;
const configuration: webpack.Configuration = {
context: webpackPaths.rootPath,
devtool: 'eval',
mode: 'development',
target: 'electron-renderer',
externals: ['fsevents', 'crypto-browserify'],
/**
* Use `module` from `webpack.config.renderer.dev.js`
*/
module: require('./webpack.config.renderer.dev').default.module,
entry: {
renderer: Object.keys(dependencies || {}),
},
output: {
path: dist,
filename: '[name].dev.dll.js',
library: {
name: 'renderer',
type: 'var',
},
},
plugins: [
new webpack.DllPlugin({
path: path.join(dist, '[name].json'),
name: '[name]',
}),
/**
* 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: 'development',
}),
new webpack.LoaderOptionsPlugin({
debug: true,
options: {
context: webpackPaths.srcPath,
output: {
path: webpackPaths.dllPath,
},
},
}),
],
};
export default merge(baseConfig, configuration);

View File

@@ -1,201 +0,0 @@
import 'webpack-dev-server';
import { execSync, spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin';
import chalk from 'chalk';
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 manifest = path.resolve(webpackPaths.dllPath, 'renderer.json');
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const requiredByDLLConfig = module.parent!.filename.includes('webpack.config.renderer.dev.dll');
/**
* Warn if the DLL is not built
*/
if (!requiredByDLLConfig && !(fs.existsSync(webpackPaths.dllPath) && fs.existsSync(manifest))) {
console.log(
chalk.black.bgYellow.bold(
'The DLL files are missing. Sit back while we build them for you with "npm run build-dll"',
),
);
execSync('npm run postinstall');
}
const configuration: webpack.Configuration = {
devtool: 'inline-source-map',
mode: 'development',
target: ['web', 'electron-renderer'],
entry: [
`webpack-dev-server/client?http://localhost:${port}/dist`,
'webpack/hot/only-dev-server',
path.join(webpackPaths.srcRendererPath, 'index.tsx'),
],
output: {
path: webpackPaths.distRendererPath,
publicPath: '/',
filename: 'renderer.dev.js',
library: {
type: 'umd',
},
},
module: {
rules: [
{
test: /\.s?css$/,
use: [
'style-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?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: [
...(requiredByDLLConfig
? []
: [
new webpack.DllReferencePlugin({
context: webpackPaths.dllPath,
manifest: require(manifest),
sourceType: 'var',
}),
]),
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 ReactRefreshWebpackPlugin(),
new HtmlWebpackPlugin({
filename: path.join('index.html'),
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
removeComments: true,
},
isBrowser: false,
env: process.env.NODE_ENV,
isDevelopment: process.env.NODE_ENV !== 'production',
nodeModules: webpackPaths.appNodeModulesPath,
templateParameters: {
web: false,
},
}),
],
node: {
__dirname: false,
__filename: false,
},
devServer: {
port,
compress: true,
hot: true,
headers: { 'Access-Control-Allow-Origin': '*' },
static: {
publicPath: '/',
},
historyApiFallback: {
verbose: true,
},
setupMiddlewares(middlewares) {
console.log('Starting preload.js builder...');
const preloadProcess = spawn('npm', ['run', 'start:preload'], {
shell: true,
stdio: 'inherit',
})
.on('close', (code: number) => process.exit(code!))
.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...');
spawn('npm', ['run', 'start:main'], {
shell: true,
stdio: 'inherit',
})
.on('close', (code: number) => {
preloadProcess.kill();
remoteProcess.kill();
process.exit(code!);
})
.on('error', (spawnError) => console.error(spawnError));
return middlewares;
},
},
};
export default merge(baseConfig, configuration);

View File

@@ -1,137 +0,0 @@
/**
* 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', 'electron-renderer'],
entry: [path.join(webpackPaths.srcRendererPath, 'index.tsx')],
output: {
path: webpackPaths.distRendererPath,
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'),
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
removeComments: true,
},
isBrowser: false,
isDevelopment: process.env.NODE_ENV !== 'production',
templateParameters: {
web: false,
},
}),
],
};
export default merge(baseConfig, configuration);

View File

@@ -1,147 +0,0 @@
import 'webpack-dev-server';
import path from 'path';
import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin';
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', 'electron-renderer'],
entry: [
`webpack-dev-server/client?http://localhost:${port}/dist`,
'webpack/hot/only-dev-server',
path.join(webpackPaths.srcRendererPath, 'index.tsx'),
],
output: {
path: webpackPaths.distRendererPath,
publicPath: '/',
filename: 'renderer.dev.js',
library: {
type: 'umd',
},
},
module: {
rules: [
{
test: /\.s?css$/,
use: [
'style-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?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 ReactRefreshWebpackPlugin(),
new HtmlWebpackPlugin({
filename: path.join('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,
env: process.env.NODE_ENV,
isDevelopment: process.env.NODE_ENV !== 'production',
nodeModules: webpackPaths.appNodeModulesPath,
templateParameters: {
web: false, // with hot reload, we don't have NGINX injecting variables
},
}),
],
node: {
__dirname: false,
__filename: false,
},
devServer: {
port,
compress: true,
hot: true,
headers: { 'Access-Control-Allow-Origin': '*' },
static: {
publicPath: '/',
},
historyApiFallback: {
verbose: true,
},
setupMiddlewares(middlewares) {
return middlewares;
},
},
};
export default merge(baseConfig, configuration);

View File

@@ -1,138 +0,0 @@
/**
* 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: 'auto',
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',
templateParameters: {
web: true,
},
}),
],
};
export default merge(baseConfig, configuration);

View File

@@ -1,46 +0,0 @@
const path = require('path');
const rootPath = path.join(__dirname, '../..');
const dllPath = path.join(__dirname, '../dll');
const srcPath = path.join(rootPath, 'src');
const assetsPath = path.join(rootPath, 'assets');
const srcMainPath = path.join(srcPath, 'main');
const srcRemotePath = path.join(srcPath, 'remote');
const srcRendererPath = path.join(srcPath, 'renderer');
const releasePath = path.join(rootPath, 'release');
const appPath = path.join(releasePath, 'app');
const appPackagePath = path.join(appPath, 'package.json');
const appNodeModulesPath = path.join(appPath, 'node_modules');
const srcNodeModulesPath = path.join(srcPath, 'node_modules');
const distPath = path.join(appPath, 'dist');
const distMainPath = path.join(distPath, 'main');
const distRemotePath = path.join(distPath, 'remote');
const distRendererPath = path.join(distPath, 'renderer');
const distWebPath = path.join(distPath, 'web');
const buildPath = path.join(releasePath, 'build');
export default {
assetsPath,
rootPath,
dllPath,
srcPath,
srcMainPath,
srcRemotePath,
srcRendererPath,
releasePath,
appPath,
appPackagePath,
appNodeModulesPath,
srcNodeModulesPath,
distPath,
distMainPath,
distRemotePath,
distRendererPath,
distWebPath,
buildPath,
};

View File

@@ -1 +0,0 @@
export default 'test-file-stub';

View File

@@ -1,8 +0,0 @@
{
"rules": {
"no-console": "off",
"global-require": "off",
"import/no-dynamic-require": "off",
"import/no-extraneous-dependencies": "off"
}
}

View File

@@ -1,33 +0,0 @@
// Check if the renderer and main bundles are built
import path from 'path';
import chalk from 'chalk';
import fs from 'fs';
import webpackPaths from '../configs/webpack.paths';
const mainPath = path.join(webpackPaths.distMainPath, 'main.js');
const remotePath = path.join(webpackPaths.distMainPath, 'remote.js');
const rendererPath = path.join(webpackPaths.distRendererPath, 'renderer.js');
if (!fs.existsSync(mainPath)) {
throw new Error(
chalk.whiteBright.bgRed.bold(
'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)) {
throw new Error(
chalk.whiteBright.bgRed.bold(
'The renderer process is not built yet. Build it by running "npm run build:renderer"',
),
);
}

View File

@@ -1,54 +0,0 @@
import fs from 'fs';
import chalk from 'chalk';
import { execSync } from 'child_process';
import { dependencies } from '../../package.json';
if (dependencies) {
const dependenciesKeys = Object.keys(dependencies);
const nativeDeps = fs
.readdirSync('node_modules')
.filter((folder) => fs.existsSync(`node_modules/${folder}/binding.gyp`));
if (nativeDeps.length === 0) {
process.exit(0);
}
try {
// Find the reason for why the dependency is installed. If it is installed
// because of a devDependency then that is okay. Warn when it is installed
// because of a dependency
const { dependencies: dependenciesObject } = JSON.parse(
execSync(`npm ls ${nativeDeps.join(' ')} --json`).toString()
);
const rootDependencies = Object.keys(dependenciesObject);
const filteredRootDependencies = rootDependencies.filter((rootDependency) =>
dependenciesKeys.includes(rootDependency)
);
if (filteredRootDependencies.length > 0) {
const plural = filteredRootDependencies.length > 1;
console.log(`
${chalk.whiteBright.bgYellow.bold(
'Webpack does not work with native dependencies.'
)}
${chalk.bold(filteredRootDependencies.join(', '))} ${
plural ? 'are native dependencies' : 'is a native dependency'
} and should be installed inside of the "./release/app" folder.
First, uninstall the packages from "./package.json":
${chalk.whiteBright.bgGreen.bold('npm uninstall your-package')}
${chalk.bold(
'Then, instead of installing the package to the root "./package.json":'
)}
${chalk.whiteBright.bgRed.bold('npm install your-package')}
${chalk.bold('Install the package to "./release/app/package.json"')}
${chalk.whiteBright.bgGreen.bold(
'cd ./release/app && npm install your-package'
)}
Read more about native dependencies at:
${chalk.bold(
'https://electron-react-boilerplate.js.org/docs/adding-dependencies/#module-structure'
)}
`);
process.exit(1);
}
} catch (e) {
console.log('Native dependencies could not be checked');
}
}

View File

@@ -1,16 +0,0 @@
import chalk from 'chalk';
export default function checkNodeEnv(expectedEnv) {
if (!expectedEnv) {
throw new Error('"expectedEnv" not set');
}
if (process.env.NODE_ENV !== expectedEnv) {
console.log(
chalk.whiteBright.bgRed.bold(
`"process.env.NODE_ENV" must be "${expectedEnv}" to use this webpack config`
)
);
process.exit(2);
}
}

View File

@@ -1,16 +0,0 @@
import chalk from 'chalk';
import detectPort from 'detect-port';
const port = process.env.PORT || '4343';
detectPort(port, (err, availablePort) => {
if (port !== String(availablePort)) {
throw new Error(
chalk.whiteBright.bgRed.bold(
`Port "${port}" on "localhost" is already in use. Please use another port. ex: PORT=4343 npm start`
)
);
} else {
process.exit(0);
}
});

View File

@@ -1,17 +0,0 @@
import rimraf from 'rimraf';
import process from 'process';
import webpackPaths from '../configs/webpack.paths';
const args = process.argv.slice(2);
const commandMap = {
dist: webpackPaths.distPath,
release: webpackPaths.releasePath,
dll: webpackPaths.dllPath,
};
args.forEach((x) => {
const pathToRemove = commandMap[x];
if (pathToRemove !== undefined) {
rimraf.sync(pathToRemove);
}
});

View File

@@ -1,9 +0,0 @@
import path from 'path';
import rimraf from 'rimraf';
import webpackPaths from '../configs/webpack.paths';
export default function deleteSourceMaps() {
rimraf.sync(path.join(webpackPaths.distMainPath, '*.js.map'));
rimraf.sync(path.join(webpackPaths.distRemotePath, '*.js.map'));
rimraf.sync(path.join(webpackPaths.distRendererPath, '*.js.map'));
}

View File

@@ -1,20 +0,0 @@
import { execSync } from 'child_process';
import fs from 'fs';
import { dependencies } from '../../release/app/package.json';
import webpackPaths from '../configs/webpack.paths';
if (
Object.keys(dependencies || {}).length > 0 &&
fs.existsSync(webpackPaths.appNodeModulesPath)
) {
const electronRebuildCmd =
'../../node_modules/.bin/electron-rebuild --force --types prod,dev,optional --module-dir .';
const cmd =
process.platform === 'win32'
? electronRebuildCmd.replace(/\//g, '\\')
: electronRebuildCmd;
execSync(cmd, {
cwd: webpackPaths.appPath,
stdio: 'inherit',
});
}

View File

@@ -1,9 +0,0 @@
import fs from 'fs';
import webpackPaths from '../configs/webpack.paths';
const { srcNodeModulesPath } = webpackPaths;
const { appNodeModulesPath } = webpackPaths;
if (!fs.existsSync(srcNodeModulesPath) && fs.existsSync(appNodeModulesPath)) {
fs.symlinkSync(appNodeModulesPath, srcNodeModulesPath, 'junction');
}

View File

@@ -1,30 +0,0 @@
const { notarize } = require('electron-notarize');
const { build } = require('../../package.json');
exports.default = async function notarizeMacos(context) {
const { electronPlatformName, appOutDir } = context;
if (electronPlatformName !== 'darwin') {
return;
}
if (process.env.CI !== 'true') {
console.warn('Skipping notarizing step. Packaging is not running in CI');
return;
}
if (!('APPLE_ID' in process.env && 'APPLE_ID_PASS' in process.env)) {
console.warn(
'Skipping notarizing step. APPLE_ID and APPLE_ID_PASS env variables must be set'
);
return;
}
const appName = context.packager.appInfo.productFilename;
await notarize({
appBundleId: build.appId,
appPath: `${appOutDir}/${appName}.app`,
appleId: process.env.APPLE_ID,
appleIdPassword: process.env.APPLE_ID_PASS,
});
};

View File

@@ -1,34 +0,0 @@
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
# Coverage directory used by tools like istanbul
coverage
.eslintcache
# Dependency directory
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
node_modules
# OSX
.DS_Store
src/i18n
release/app/dist
release/build
.erb/dll
.idea
npm-debug.log.*
*.css.d.ts
*.sass.d.ts
*.scss.d.ts
# eslint ignores hidden directories by default:
# https://github.com/eslint/eslint/issues/8429
!.erb

View File

@@ -1,97 +0,0 @@
module.exports = {
extends: ['erb', 'plugin:typescript-sort-keys/recommended'],
ignorePatterns: ['.erb/*', 'server'],
parser: '@typescript-eslint/parser',
parserOptions: {
createDefaultProgram: true,
ecmaVersion: 12,
parser: '@typescript-eslint/parser',
project: './tsconfig.json',
sourceType: 'module',
tsconfigRootDir: './',
},
plugins: ['@typescript-eslint', 'import', 'sort-keys-fix'],
rules: {
'@typescript-eslint/naming-convention': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-shadow': ['off'],
'@typescript-eslint/no-unused-vars': ['error'],
'@typescript-eslint/no-use-before-define': ['error'],
'default-case': 'off',
'import/extensions': 'off',
'import/no-absolute-path': 'off',
// A temporary hack related to IDE not resolving correct package.json
'import/no-extraneous-dependencies': 'off',
'import/no-unresolved': 'error',
'import/order': [
'error',
{
alphabetize: {
caseInsensitive: true,
order: 'asc',
},
groups: ['builtin', 'external', 'internal', ['parent', 'sibling']],
'newlines-between': 'never',
pathGroups: [
{
group: 'external',
pattern: 'react',
position: 'before',
},
],
pathGroupsExcludedImportTypes: ['react'],
},
],
'import/prefer-default-export': 'off',
'jsx-a11y/click-events-have-key-events': 'off',
'jsx-a11y/interactive-supports-focus': 'off',
'jsx-a11y/media-has-caption': 'off',
'no-await-in-loop': 'off',
'no-console': 'off',
'no-nested-ternary': 'off',
'no-restricted-syntax': 'off',
'no-shadow': 'off',
'no-underscore-dangle': 'off',
'no-unused-vars': 'off',
'no-use-before-define': '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-sort-props': [
'error',
{
callbacksLast: true,
ignoreCase: false,
noSortAlphabetically: false,
reservedFirst: true,
shorthandFirst: true,
shorthandLast: false,
},
],
'react/no-array-index-key': 'off',
'react/react-in-jsx-scope': 'off',
'react/require-default-props': 'off',
'sort-keys-fix/sort-keys-fix': 'warn',
},
settings: {
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx'],
},
'import/resolver': {
// See https://github.com/benmosher/eslint-plugin-import/issues/1396#issuecomment-575727774 for line below
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
typescript: {
alwaysTryTypes: true,
project: './tsconfig.json',
},
webpack: {
config: require.resolve('./.erb/configs/webpack.config.eslint.ts'),
},
},
},
};

33
.gitignore vendored
View File

@@ -1,31 +1,6 @@
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
# Coverage directory used by tools like istanbul
coverage
.eslintcache
# Dependency directory
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
node_modules
# OSX
dist
out
.DS_Store
release/app/dist
release/build
.erb/dll
.idea
npm-debug.log.*
*.css.d.ts
*.sass.d.ts
*.scss.d.ts
.env*
.eslintcache
*.log*

View File

@@ -1,4 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged

6
.prettierignore Normal file
View File

@@ -0,0 +1,6 @@
out
dist
pnpm-lock.yaml
LICENSE.md
tsconfig.json
tsconfig.*.json

View File

@@ -1,22 +0,0 @@
{
"printWidth": 100,
"semi": true,
"singleQuote": true,
"tabWidth": 4,
"useTabs": false,
"overrides": [
{
"files": ["**/*.css", "**/*.scss", "**/*.html"],
"options": {
"singleQuote": true
}
}
],
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "always",
"proseWrap": "never",
"htmlWhitespaceSensitivity": "strict",
"endOfLine": "lf",
"singleAttributePerLine": true
}

12
.prettierrc.yaml Normal file
View File

@@ -0,0 +1,12 @@
singleQuote: true
semi: true
printWidth: 100
tabWidth: 4
trailingComma: all
useTabs: false
arrowParens: always
proseWrap: never
htmlWhitespaceSensitivity: strict
endOfLine: lf
singleAttributePerLine: true
bracketSpacing: true

View File

@@ -1,10 +1,3 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"stylelint.vscode-stylelint",
"esbenp.prettier-vscode",
"clinyong.vscode-css-modules",
"Huuums.vscode-fast-folder-structure"
]
"recommendations": ["dbaeumer.vscode-eslint"]
}

63
.vscode/launch.json vendored
View File

@@ -1,28 +1,39 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Electron: Main",
"type": "node",
"request": "launch",
"protocol": "inspector",
"runtimeExecutable": "npm",
"runtimeArgs": ["run start:main --inspect=5858 --remote-debugging-port=9223"],
"preLaunchTask": "Start Webpack Dev"
},
{
"name": "Electron: Renderer",
"type": "chrome",
"request": "attach",
"port": 9223,
"webRoot": "${workspaceFolder}",
"timeout": 15000
}
],
"compounds": [
{
"name": "Electron: All",
"configurations": ["Electron: Main", "Electron: Renderer"]
}
]
"version": "0.2.0",
"configurations": [
{
"name": "Debug Main Process",
"type": "node",
"request": "launch",
"cwd": "${workspaceRoot}",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite",
"windows": {
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd"
},
"runtimeArgs": ["--sourcemap"],
"env": {
"REMOTE_DEBUGGING_PORT": "9222"
}
},
{
"name": "Debug Renderer Process",
"port": 9222,
"request": "attach",
"type": "chrome",
"webRoot": "${workspaceFolder}/src/renderer",
"timeout": 60000,
"presentation": {
"hidden": true
}
}
],
"compounds": [
{
"name": "Debug All",
"configurations": ["Debug Main Process", "Debug Renderer Process"],
"presentation": {
"order": 1
}
}
]
}

10
.vscode/settings.json vendored
View File

@@ -1,4 +1,13 @@
{
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"files.associations": {
".eslintrc": "jsonc",
".prettierrc": "jsonc",
@@ -40,7 +49,6 @@
"typescript.preferences.importModuleSpecifier": "non-relative",
"stylelint.validate": ["css", "scss", "typescript", "typescriptreact"],
"typescript.updateImportsOnFileMove.enabled": "always",
"[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"typescript.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces": true,
"folderTemplates.structures": [

25
.vscode/tasks.json vendored
View File

@@ -1,25 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"label": "Start Webpack Dev",
"script": "start:renderer",
"options": {
"cwd": "${workspaceFolder}"
},
"isBackground": true,
"problemMatcher": {
"owner": "custom",
"pattern": {
"regexp": "____________"
},
"background": {
"activeOnStart": true,
"beginsPattern": "Compiling\\.\\.\\.$",
"endsPattern": "(Compiled successfully|Failed to compile)\\.$"
}
}
}
]
}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>

BIN
build/icon.icns Normal file
View File

Binary file not shown.

BIN
build/icon.ico Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

BIN
build/icon.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

3
dev-app-update.yml Normal file
View File

@@ -0,0 +1,3 @@
provider: generic
url: https://example.com/auto-updates
updaterCacheDirName: feishin-updater

43
electron-builder.yml Normal file
View File

@@ -0,0 +1,43 @@
appId: com.electron.app
productName: feishin
directories:
buildResources: build
files:
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
- resources/**
win:
executableName: feishin
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
entitlementsInherit: build/entitlements.mac.plist
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
notarize: false
dmg:
artifactName: ${name}-${version}.${ext}
linux:
target:
- AppImage
- snap
- deb
maintainer: electronjs.org
category: Utility
appImage:
artifactName: ${name}-${version}.${ext}
npmRebuild: false
publish:
provider: generic
url: https://example.com/auto-updates

35
electron.vite.config.ts Normal file
View File

@@ -0,0 +1,35 @@
import react from '@vitejs/plugin-react';
import { externalizeDepsPlugin, UserConfig } from 'electron-vite';
import { resolve } from 'path';
const config: UserConfig = {
main: {
plugins: [externalizeDepsPlugin()],
},
preload: {
plugins: [externalizeDepsPlugin()],
},
renderer: {
css: {
modules: {
generateScopedName: '[name]__[local]__[hash:base64:5]',
localsConvention: 'camelCase',
},
},
plugins: [react()],
resolve: {
alias: {
'/@/i18n': resolve('src/i18n'),
'/@/main': resolve('src/main'),
'/@/renderer': resolve('src/renderer'),
'/@/renderer/api': resolve('src/renderer/api'),
'/@/renderer/components': resolve('src/renderer/components'),
'/@/renderer/features': resolve('src/renderer/features'),
'/@/renderer/hooks': resolve('src/renderer/hooks'),
'/@/shared': resolve('src/shared'),
},
},
},
};
export default config;

48
eslint.config.mjs Normal file
View File

@@ -0,0 +1,48 @@
import eslintConfigPrettier from '@electron-toolkit/eslint-config-prettier';
import tseslint from '@electron-toolkit/eslint-config-ts';
import perfectionist from 'eslint-plugin-perfectionist';
import eslintPluginReact from 'eslint-plugin-react';
import eslintPluginReactHooks from 'eslint-plugin-react-hooks';
import eslintPluginReactRefresh from 'eslint-plugin-react-refresh';
export default tseslint.config(
{ ignores: ['**/node_modules', '**/dist', '**/out'] },
tseslint.configs.recommended,
perfectionist.configs['recommended-natural'],
eslintPluginReact.configs.flat.recommended,
eslintPluginReact.configs.flat['jsx-runtime'],
{
settings: {
react: {
version: 'detect',
},
},
},
{
files: ['**/*.{ts,tsx}'],
plugins: {
'react-hooks': eslintPluginReactHooks,
'react-refresh': eslintPluginReactRefresh,
},
rules: {
...eslintPluginReactHooks.configs.recommended.rules,
...eslintPluginReactRefresh.configs.vite.rules,
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-unused-vars': 'warn',
curly: ['error', 'all'],
indent: [
'error',
'tab',
{
offsetTernaryExpressions: true,
SwitchCase: 1,
},
],
'no-unused-vars': 'off',
'no-use-before-define': 'off',
quotes: ['error', 'single'],
semi: ['error', 'always'],
},
},
eslintConfigPrettier,
);

View File

@@ -1,27 +0,0 @@
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;
location ${PUBLIC_PATH} {
alias /usr/share/nginx/html/;
try_files $uri $uri/ /index.html =404;
}
location ${PUBLIC_PATH}settings.js {
alias /etc/nginx/conf.d/settings.js;
}
location ${PUBLIC_PATH}/settings.js {
alias /etc/nginx/conf.d/settings.js;
}
}

6057
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,297 +1,25 @@
{
"name": "feishin",
"productName": "Feishin",
"description": "Feishin music server",
"version": "0.13.0",
"version": "1.0.0",
"description": "An Electron application with React and TypeScript",
"main": "./out/main/index.js",
"author": "example.com",
"homepage": "https://electron-vite.org",
"scripts": {
"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: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:web": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.web.prod.ts",
"build:docker": "docker build -t jeffvli/feishin .",
"rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app",
"lint": "concurrently \"npm run lint:code\" \"npm run lint:styles\"",
"lint:code": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx --fix",
"lint:styles": "npx stylelint **/*.tsx --fix",
"package": "node --import tsx ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never",
"package:pr": "node --import tsx ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --win --mac --linux",
"package:pr:macos": "node --import tsx ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --mac",
"package:pr:windows": "node --import tsx ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --win",
"package:pr:linux": "node --import tsx ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --linux",
"package:dev": "node --import tsx ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --dir",
"postinstall": "node --import tsx .erb/scripts/check-native-dep.js && electron-builder install-app-deps && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts",
"start": "node --import tsx ./.erb/scripts/check-port-in-use.js && npm run start:renderer",
"start:main": "cross-env NODE_ENV=development NODE_OPTIONS=\"--import tsx\" 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: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:web": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.web.ts",
"test": "jest",
"prepare": "husky install",
"i18next": "i18next -c src/i18n/i18next-parser.config.js",
"prod:buildserver": "pwsh -c \"./scripts/server-build.ps1\"",
"prod:publishserver": "pwsh -c \"./scripts/server-publish.ps1\""
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"cross-env NODE_ENV=development eslint --cache"
],
"*.json,.{eslintrc,prettierrc}": [
"prettier --ignore-path .eslintignore --parser json --write"
],
"*.{css,scss}": [
"prettier --ignore-path .eslintignore --single-quote --write"
],
"*.{html,md,yml}": [
"prettier --ignore-path .eslintignore --single-quote --write"
]
},
"build": {
"productName": "Feishin",
"appId": "org.jeffvli.feishin",
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"asar": true,
"asarUnpack": "**\\*.{node,dll}",
"files": [
"dist",
"node_modules",
"package.json"
],
"afterSign": ".erb/scripts/notarize.js",
"electronVersion": "36.1.0",
"mac": {
"target": {
"target": "default",
"arch": [
"arm64",
"x64"
]
},
"icon": "assets/icons/icon.icns",
"type": "distribution",
"hardenedRuntime": true,
"entitlements": "assets/entitlements.mac.plist",
"entitlementsInherit": "assets/entitlements.mac.plist",
"gatekeeperAssess": false
},
"dmg": {
"contents": [
{
"x": 130,
"y": 220
},
{
"x": 410,
"y": 220,
"type": "link",
"path": "/Applications"
}
]
},
"win": {
"target": [
"nsis",
"zip"
],
"icon": "assets/icons/icon.ico"
},
"deb": {
"depends": [
"libgssapi_krb5.so.2",
"libavahi-common.so.3",
"libavahi-client.so.3",
"libkrb5.so.3",
"libkrb5support.so.0",
"libkeyutils.so.1",
"libcups.so.2"
]
},
"rpm": {
"depends": [
"libgssapi_krb5.so.2",
"libavahi-common.so.3",
"libavahi-client.so.3",
"libkrb5.so.3",
"libkrb5support.so.0",
"libkeyutils.so.1",
"libcups.so.2"
]
},
"freebsd": {
"depends": [
"libgssapi_krb5.so.2",
"libavahi-common.so.3",
"libavahi-client.so.3",
"libkrb5.so.3",
"libkrb5support.so.0",
"libkeyutils.so.1",
"libcups.so.2"
]
},
"linux": {
"target": [
"AppImage",
"tar.xz"
],
"icon": "assets/icons/icon.png",
"category": "AudioVideo;Audio;Player"
},
"directories": {
"app": "release/app",
"buildResources": "assets",
"output": "release/build"
},
"extraResources": [
"./assets/**"
],
"publish": {
"provider": "github",
"owner": "jeffvli",
"repo": "feishin"
}
},
"repository": {
"type": "git",
"url": "git+https://github.com/jeffvli/feishin.git"
},
"author": {
"name": "jeffvli",
"url": "https://github.com/jeffvli/"
},
"contributors": [],
"license": "GPL-3.0",
"bugs": {
"url": "https://github.com/jeffvli/feishin/issues"
},
"keywords": [
"subsonic",
"navidrome",
"airsonic",
"jellyfin",
"react",
"electron"
],
"homepage": "https://github.com/jeffvli/feishin",
"jest": {
"testURL": "http://localhost/",
"testEnvironment": "jsdom",
"transform": {
"\\.(ts|tsx|js|jsx)$": "ts-jest"
},
"moduleNameMapper": {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/.erb/mocks/fileMock.js",
"\\.(css|less|sass|scss)$": "identity-obj-proxy"
},
"moduleFileExtensions": [
"js",
"jsx",
"ts",
"tsx",
"json"
],
"moduleDirectories": [
"node_modules",
"release/app/node_modules"
],
"testPathIgnorePatterns": [
"release/app/dist"
],
"setupFiles": [
"./.erb/scripts/check-build-exists.ts"
]
},
"devDependencies": {
"@electron/rebuild": "^3.6.0",
"@pmmmwh/react-refresh-webpack-plugin": "0.5.5",
"@stylelint/postcss-css-in-js": "^0.38.0",
"@teamsupercell/typings-for-css-modules-loader": "^2.5.1",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.0.0",
"@types/dompurify": "^3.0.5",
"@types/electron-localshortcut": "^3.1.0",
"@types/jest": "^27.4.1",
"@types/lodash": "^4.14.188",
"@types/md5": "^2.3.2",
"@types/node": "^17.0.23",
"@types/react": "^18.0.25",
"@types/react-dom": "^18.0.8",
"@types/react-test-renderer": "^17.0.1",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
"@types/react-window-infinite-loader": "^1.0.6",
"@types/styled-components": "^5.1.26",
"@types/terser-webpack-plugin": "^5.0.4",
"@types/webpack-bundle-analyzer": "^4.4.1",
"@types/webpack-env": "^1.16.3",
"@typescript-eslint/eslint-plugin": "^5.47.0",
"@typescript-eslint/parser": "^5.47.0",
"browserslist-config-erb": "^0.0.3",
"chalk": "^4.1.2",
"concurrently": "^7.1.0",
"core-js": "^3.21.1",
"cross-env": "^7.0.3",
"css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^3.4.1",
"detect-port": "^1.3.0",
"electron": "^36.1.0",
"electron-builder": "^24.13.3",
"electron-devtools-installer": "^3.2.0",
"electron-notarize": "^1.2.1",
"electronmon": "^2.0.2",
"eslint": "^8.30.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-erb": "^4.0.3",
"eslint-import-resolver-typescript": "^2.7.1",
"eslint-import-resolver-webpack": "^0.13.2",
"eslint-plugin-compat": "^4.2.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jest": "^26.1.3",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-promise": "^6.0.0",
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^4.4.0",
"eslint-plugin-sort-keys-fix": "^1.1.2",
"eslint-plugin-typescript-sort-keys": "^2.1.0",
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.5.0",
"husky": "^7.0.4",
"i18next-parser": "^9.0.2",
"identity-obj-proxy": "^3.0.0",
"jest": "^27.5.1",
"lint-staged": "^12.3.7",
"mini-css-extract-plugin": "^2.6.0",
"postcss-scss": "^4.0.4",
"postcss-styled-syntax": "^0.5.0",
"postcss-syntax": "^0.36.2",
"prettier": "^3.3.3",
"react-refresh": "^0.12.0",
"react-refresh-typescript": "^2.0.4",
"react-test-renderer": "^18.0.0",
"rimraf": "^3.0.2",
"sass": "^1.49.11",
"sass-loader": "^12.6.0",
"style-loader": "^3.3.1",
"stylelint": "^15.10.3",
"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-styled-components": "^0.1.1",
"terser-webpack-plugin": "^5.3.1",
"ts-jest": "^27.1.4",
"ts-loader": "^9.2.8",
"ts-node": "^10.9.2",
"tsconfig-paths-webpack-plugin": "^4.0.0",
"tsx": "^4.16.2",
"typescript": "^5.2.2",
"typescript-plugin-styled-components": "^3.0.0",
"url-loader": "^4.1.1",
"webpack": "^5.94.0",
"webpack-bundle-analyzer": "^4.5.0",
"webpack-cli": "^4.9.2",
"webpack-dev-server": "^4.8.0",
"webpack-merge": "^5.8.0"
"format": "prettier --write .",
"lint": "eslint --cache --fix .",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"dev:watch": "electron-vite dev --watch",
"build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac",
"build:linux": "electron-vite build && electron-builder --linux"
},
"dependencies": {
"@ag-grid-community/client-side-row-model": "^28.2.1",
@@ -299,6 +27,8 @@
"@ag-grid-community/infinite-row-model": "^28.2.1",
"@ag-grid-community/react": "^28.2.1",
"@ag-grid-community/styles": "^28.2.1",
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^4.0.0",
"@emotion/react": "^11.10.4",
"@mantine/core": "^6.0.17",
"@mantine/dates": "^6.0.17",
@@ -315,6 +45,7 @@
"audiomotion-analyzer": "^4.5.0",
"auto-text-size": "^0.2.3",
"axios": "^1.6.0",
"cheerio": "^1.0.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"dayjs": "^1.11.6",
@@ -323,7 +54,7 @@
"electron-localshortcut": "^3.2.1",
"electron-log": "^5.1.1",
"electron-store": "^8.1.0",
"electron-updater": "^6.3.1",
"electron-updater": "^6.3.9",
"fast-average-color": "^9.3.0",
"format-duration": "^2.0.0",
"framer-motion": "^11.0.0",
@@ -336,11 +67,12 @@
"lodash": "^4.17.21",
"md5": "^2.3.0",
"memoize-one": "^6.0.0",
"mpris-service": "^2.1.2",
"nanoid": "^3.3.3",
"net": "^1.0.2",
"node-mpv": "github:jeffvli/Node-MPV#32b4d64395289ad710c41d481d2707a7acfc228f",
"overlayscrollbars": "^2.2.1",
"overlayscrollbars-react": "^0.5.1",
"overlayscrollbars": "^2.11.1",
"overlayscrollbars-react": "^0.5.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^3.1.4",
@@ -356,33 +88,112 @@
"semver": "^7.5.4",
"styled-components": "^6.0.8",
"swiper": "^9.3.1",
"ws": "^8.18.2",
"zod": "^3.22.3",
"zustand": "^4.3.9"
},
"resolutions": {
"styled-components": "^6",
"entities": "2.2.0"
},
"overrides": {
"entities": "2.2.0"
},
"devEngines": {
"runtime": {
"name": "node",
"version": ">=18.x",
"onFail": "error"
},
"packageManager": {
"name": "npm",
"version": ">=7.x",
"onFail": "error"
}
},
"browserslist": [],
"electronmon": {
"patterns": [
"!server",
"!src/renderer"
]
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.0.0",
"@electron-toolkit/tsconfig": "^1.0.1",
"@electron/rebuild": "^3.6.0",
"@pmmmwh/react-refresh-webpack-plugin": "0.5.5",
"@stylelint/postcss-css-in-js": "^0.38.0",
"@teamsupercell/typings-for-css-modules-loader": "^2.5.1",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.0.0",
"@types/dompurify": "^3.0.5",
"@types/electron-localshortcut": "^3.1.0",
"@types/jest": "^27.4.1",
"@types/lodash": "^4.14.188",
"@types/md5": "^2.3.2",
"@types/node": "^22.14.1",
"@types/react": "^19.1.1",
"@types/react-dom": "^19.1.2",
"@types/react-test-renderer": "^17.0.1",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
"@types/react-window-infinite-loader": "^1.0.6",
"@types/styled-components": "^5.1.26",
"@types/terser-webpack-plugin": "^5.0.4",
"@types/webpack-bundle-analyzer": "^4.4.1",
"@types/webpack-env": "^1.16.3",
"@typescript-eslint/eslint-plugin": "^5.47.0",
"@typescript-eslint/parser": "^5.47.0",
"@vitejs/plugin-react": "^4.3.4",
"browserslist-config-erb": "^0.0.3",
"chalk": "^4.1.2",
"concurrently": "^7.1.0",
"core-js": "^3.21.1",
"cross-env": "^7.0.3",
"css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^3.4.1",
"detect-port": "^1.3.0",
"electron": "^35.1.5",
"electron-builder": "^25.1.8",
"electron-devtools-installer": "^3.2.0",
"electron-notarize": "^1.2.1",
"electron-vite": "^3.1.0",
"electronmon": "^2.0.2",
"eslint": "^9.24.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-erb": "^4.0.3",
"eslint-import-resolver-typescript": "^2.7.1",
"eslint-import-resolver-webpack": "^0.13.2",
"eslint-plugin-compat": "^4.2.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jest": "^26.1.3",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-perfectionist": "^4.13.0",
"eslint-plugin-prettier": "^5.4.0",
"eslint-plugin-promise": "^6.0.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"eslint-plugin-sort-keys-fix": "^1.1.2",
"eslint-plugin-typescript-sort-keys": "^2.1.0",
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.5.0",
"husky": "^7.0.4",
"i18next-parser": "^9.0.2",
"identity-obj-proxy": "^3.0.0",
"jest": "^27.5.1",
"lint-staged": "^12.3.7",
"mini-css-extract-plugin": "^2.6.0",
"postcss-scss": "^4.0.4",
"postcss-styled-syntax": "^0.5.0",
"postcss-syntax": "^0.36.2",
"prettier": "^3.5.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-refresh": "^0.12.0",
"react-refresh-typescript": "^2.0.4",
"react-test-renderer": "^18.0.0",
"rimraf": "^3.0.2",
"sass": "^1.49.11",
"sass-embedded": "^1.89.0",
"sass-loader": "^12.6.0",
"style-loader": "^3.3.1",
"stylelint": "^15.10.3",
"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-styled-components": "^0.1.1",
"terser-webpack-plugin": "^5.3.1",
"ts-jest": "^27.1.4",
"ts-loader": "^9.2.8",
"ts-node": "^10.9.2",
"tsconfig-paths-webpack-plugin": "^4.0.0",
"tsx": "^4.16.2",
"typescript": "^5.8.3",
"typescript-plugin-styled-components": "^3.0.0",
"url-loader": "^4.1.1",
"vite": "^6.2.6",
"webpack": "^5.94.0",
"webpack-bundle-analyzer": "^4.5.0",
"webpack-cli": "^4.9.2",
"webpack-dev-server": "^4.8.0",
"webpack-merge": "^5.8.0"
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +0,0 @@
{
"name": "feishin",
"version": "0.13.0",
"description": "",
"main": "./dist/main/main.js",
"author": {
"name": "jeffvli",
"url": "https://github.com/jeffvli/"
},
"scripts": {
"electron-rebuild": "node -r ts-node/register ../../.erb/scripts/electron-rebuild.js",
"link-modules": "node -r ts-node/register ../../.erb/scripts/link-modules.ts",
"postinstall": "npm run electron-rebuild && npm run link-modules"
},
"dependencies": {
"cheerio": "^1.0.0-rc.12",
"mpris-service": "^2.1.2",
"ws": "^8.18.0"
},
"devDependencies": {
"electron": "36.1.0"
},
"resolutions": {
"xml2js": "0.5.0"
},
"overrides": {
"xml2js": "0.5.0"
},
"license": "GPL-3.0"
}

BIN
resources/icon.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -1,9 +0,0 @@
import '@testing-library/jest-dom';
import { render } from '@testing-library/react';
import { App } from '../renderer/app';
describe('App', () => {
it('should render', () => {
expect(render(<App />)).toBeTruthy();
});
});

View File

@@ -1,52 +1,53 @@
import { PostProcessorModule, TOptions, StringMap } from 'i18next';
import { PostProcessorModule, StringMap, TOptions } from 'i18next';
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import cs from './locales/cs.json';
import de from './locales/de.json';
import en from './locales/en.json';
import es from './locales/es.json';
import fa from './locales/fa.json';
import fi from './locales/fi.json';
import fr from './locales/fr.json';
import ja from './locales/ja.json';
import pl from './locales/pl.json';
import zhHans from './locales/zh-Hans.json';
import de from './locales/de.json';
import hu from './locales/hu.json';
import id from './locales/id.json';
import it from './locales/it.json';
import ru from './locales/ru.json';
import ptBr from './locales/pt-BR.json';
import sr from './locales/sr.json';
import sv from './locales/sv.json';
import cs from './locales/cs.json';
import ja from './locales/ja.json';
import ko from './locales/ko.json';
import nbNO from './locales/nb-NO.json';
import nl from './locales/nl.json';
import zhHant from './locales/zh-Hant.json';
import fa from './locales/fa.json';
import ko from './locales/ko.json';
import pl from './locales/pl.json';
import ptBr from './locales/pt-BR.json';
import ru from './locales/ru.json';
import sr from './locales/sr.json';
import sv from './locales/sv.json';
import ta from './locales/ta.json';
import id from './locales/id.json';
import fi from './locales/fi.json';
import hu from './locales/hu.json';
import zhHans from './locales/zh-Hans.json';
import zhHant from './locales/zh-Hant.json';
const resources = {
cs: { translation: cs },
de: { translation: de },
en: { translation: en },
es: { translation: es },
de: { translation: de },
it: { translation: it },
ru: { translation: ru },
'pt-BR': { translation: ptBr },
fa: { translation: fa },
fi: { translation: fi },
fr: { translation: fr },
hu: { translation: hu },
id: { translation: id },
it: { translation: it },
ja: { translation: ja },
ko: { translation: ko },
'nb-NO': { translation: nbNO },
nl: { translation: nl },
pl: { translation: pl },
'zh-Hans': { translation: zhHans },
'zh-Hant': { translation: zhHant },
'pt-BR': { translation: ptBr },
ru: { translation: ru },
sr: { translation: sr },
sv: { translation: sv },
cs: { translation: cs },
nl: { translation: nl },
'nb-NO': { translation: nbNO },
ta: { translation: ta },
id: { translation: id },
fi: { translation: fi },
hu: { translation: hu },
'zh-Hans': { translation: zhHans },
'zh-Hant': { translation: zhHant },
};
export const languages = [
@@ -141,35 +142,34 @@ export const languages = [
];
const lowerCasePostProcessor: PostProcessorModule = {
type: 'postProcessor',
name: 'lowerCase',
process: (value: string) => {
return value.toLocaleLowerCase();
},
type: 'postProcessor',
};
const upperCasePostProcessor: PostProcessorModule = {
type: 'postProcessor',
name: 'upperCase',
process: (value: string) => {
return value.toLocaleUpperCase();
},
type: 'postProcessor',
};
const titleCasePostProcessor: PostProcessorModule = {
type: 'postProcessor',
name: 'titleCase',
process: (value: string) => {
return value.replace(/\S\S*/g, (txt) => {
return txt.charAt(0).toLocaleUpperCase() + txt.slice(1).toLowerCase();
});
},
type: 'postProcessor',
};
const ignoreSentenceCaseLanguages = ['de'];
const sentenceCasePostProcessor: PostProcessorModule = {
type: 'postProcessor',
name: 'sentenceCase',
process: (value: string, _key: string, _options: TOptions<StringMap>, translator: any) => {
const sentences = value.split('. ');
@@ -185,6 +185,7 @@ const sentenceCasePostProcessor: PostProcessorModule = {
})
.join('. ');
},
type: 'postProcessor',
};
i18n.use(lowerCasePostProcessor)
.use(upperCasePostProcessor)

View File

@@ -1,12 +1,13 @@
import axios, { AxiosResponse } from 'axios';
import { load } from 'cheerio';
import { orderSearchResults } from './shared';
import {
LyricSource,
InternetProviderLyricResponse,
InternetProviderLyricSearchResponse,
LyricSearchQuery,
} from '../../../../renderer/api/types';
LyricSource,
} from '.';
import { orderSearchResults } from './shared';
const SEARCH_URL = 'https://genius.com/api/search/song';
@@ -17,20 +18,6 @@ export interface GeniusResponse {
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;
@@ -38,6 +25,35 @@ export interface Hit {
type: string;
}
export interface Meta {
status: number;
}
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 Response {
next_page: number;
sections: Section[];
}
export interface Result {
_type: string;
annotation_count: number;
@@ -69,24 +85,9 @@ export interface Result {
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 Section {
hits: Hit[];
type: string;
}
export interface Stats {
@@ -94,6 +95,27 @@ export interface Stats {
unreviewed_annotations: number;
}
export async function getLyricsBySongId(url: string): Promise<null | string> {
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 getSearchResults(
params: LyricSearchQuery,
): Promise<InternetProviderLyricSearchResponse[] | null> {
@@ -133,9 +155,33 @@ export async function getSearchResults(
return orderSearchResults({ params, results: songResults });
}
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,
};
}
async function getSongId(
params: LyricSearchQuery,
): Promise<Omit<InternetProviderLyricResponse, 'lyrics'> | null> {
): Promise<null | Omit<InternetProviderLyricResponse, 'lyrics'>> {
let result: AxiosResponse<GeniusResponse>;
try {
result = await axios.get(SEARCH_URL, {
@@ -162,48 +208,3 @@ async function getSongId(
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,
};
}

View File

@@ -1,36 +1,76 @@
import { ipcMain } from 'electron';
import { store } from '../settings/index';
import {
getLyricsBySongId as getGenius,
query as queryGenius,
getSearchResults as searchGenius,
getLyricsBySongId as getGenius,
} from './genius';
import {
getLyricsBySongId as getLrcLib,
query as queryLrclib,
getSearchResults as searchLrcLib,
getLyricsBySongId as getLrcLib,
} from './lrclib';
import {
getLyricsBySongId as getNetease,
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>;
export enum LyricSource {
GENIUS = 'Genius',
LRCLIB = 'lrclib.net',
NETEASE = 'NetEase',
}
export type FullLyricsMetadata = Omit<InternetProviderLyricResponse, 'id' | 'lyrics' | 'source'> & {
lyrics: LyricsResponse;
remote: boolean;
source: 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 LyricGetQuery = {
remoteSongId: string;
remoteSource: LyricSource;
song: Song;
};
export type LyricOverride = Omit<InternetProviderLyricResponse, 'lyrics'>;
export type LyricSearchQuery = {
album?: string;
artist?: string;
duration?: number;
name?: string;
};
export type LyricsResponse = string | SynchronizedLyricsArray;
export type SynchronizedLyricsArray = Array<[number, string]>;
type CachedLyrics = Record<LyricSource, InternetProviderLyricResponse>;
type GetFetcher = (id: string) => Promise<null | string>;
type SearchFetcher = (
params: LyricSearchQuery,
) => Promise<InternetProviderLyricSearchResponse[] | null>;
type GetFetcher = (id: string) => Promise<string | null>;
type CachedLyrics = Record<LyricSource, InternetProviderLyricResponse>;
type SongFetcher = (params: LyricSearchQuery) => Promise<InternetProviderLyricResponse | null>;
const FETCHERS: Record<LyricSource, SongFetcher> = {
[LyricSource.GENIUS]: queryGenius,
@@ -54,7 +94,7 @@ const MAX_CACHED_ITEMS = 10;
const lyricCache = new Map<string, CachedLyrics>();
const getRemoteLyrics = async (song: QueueSong) => {
const getRemoteLyrics = async (song: any) => {
const sources = store.get('lyrics', []) as LyricSource[];
const cached = lyricCache.get(song.id);
@@ -122,7 +162,7 @@ const searchRemoteLyrics = async (params: LyricSearchQuery) => {
return results;
};
const getRemoteLyricsById = async (params: LyricGetQuery): Promise<string | null> => {
const getRemoteLyricsById = async (params: LyricGetQuery): Promise<null | string> => {
const { remoteSongId, remoteSource } = params;
const response = await GET_FETCHERS[remoteSource](remoteSongId);
@@ -133,7 +173,7 @@ const getRemoteLyricsById = async (params: LyricGetQuery): Promise<string | null
return response;
};
ipcMain.handle('lyric-by-song', async (_event, song: QueueSong) => {
ipcMain.handle('lyric-by-song', async (_event, song: any) => {
const lyric = await getRemoteLyrics(song);
return lyric;
});

View File

@@ -1,12 +1,13 @@
// 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';
} from '.';
import { orderSearchResults } from './shared';
const FETCH_URL = 'https://lrclib.net/api/get';
const SEEARCH_URL = 'https://lrclib.net/api/search';
@@ -29,10 +30,23 @@ export interface LrcLibTrackResponse {
isrc: string;
lang: string;
name: string;
plainLyrics: string | null;
plainLyrics: null | string;
releaseDate: string;
spotifyId: string;
syncedLyrics: string | null;
syncedLyrics: null | string;
}
export async function getLyricsBySongId(songId: string): Promise<null | string> {
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 getSearchResults(
@@ -69,19 +83,6 @@ export async function getSearchResults(
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> {

View File

@@ -1,46 +1,18 @@
import axios, { AxiosResponse } from 'axios';
import { LyricSource } from '../../../../renderer/api/types';
import { orderSearchResults } from './shared';
import type {
import {
InternetProviderLyricResponse,
InternetProviderLyricSearchResponse,
LyricSearchQuery,
} from '/@/renderer/api/types';
LyricSource,
} from '.';
import { orderSearchResults } from './shared';
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;
@@ -67,6 +39,53 @@ export interface Artist {
trans: null;
}
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;
rtype: number;
rUrl: null;
status: number;
transNames?: string[];
}
export async function getLyricsBySongId(songId: string): Promise<null | string> {
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 getSearchResults(
params: LyricSearchQuery,
): Promise<InternetProviderLyricSearchResponse[] | null> {
@@ -110,38 +129,6 @@ export async function getSearchResults(
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> {
@@ -165,3 +152,17 @@ export async function query(
source: LyricSource.NETEASE,
};
}
async function getMatchedLyrics(
params: LyricSearchQuery,
): Promise<null | Omit<InternetProviderLyricResponse, 'lyrics'>> {
const results = await getSearchResults(params);
const firstMatch = results?.[0];
if (!firstMatch || (firstMatch?.score && firstMatch.score > 0.5)) {
return null;
}
return firstMatch;
}

View File

@@ -1,4 +1,5 @@
import Fuse from 'fuse.js';
import {
InternetProviderLyricSearchResponse,
LyricSearchQuery,

View File

@@ -1,10 +1,11 @@
import console from 'console';
import { rm } from 'fs/promises';
import { pid } from 'node:process';
import { app, ipcMain } from 'electron';
import { rm } from 'fs/promises';
import uniq from 'lodash/uniq';
import MpvAPI from 'node-mpv';
import { getMainWindow, sendToastToRenderer } from '../../../main';
import { pid } from 'node:process';
import { getMainWindow, sendToastToRenderer } from '../../../index';
import { createLog, isWindows } from '../../../utils';
import { store } from '../settings';
@@ -86,7 +87,7 @@ const createMpv = async (data: {
extraParameters?: string[];
properties?: Record<string, any>;
}): Promise<MpvAPI> => {
const { extraParameters, properties, binaryPath } = data;
const { binaryPath, extraParameters, properties } = data;
const params = uniq([...DEFAULT_MPV_PARAMETERS(extraParameters), ...(extraParameters || [])]);
@@ -174,7 +175,7 @@ ipcMain.on('player-set-properties', async (_event, data: Record<string, any>) =>
} else {
getMpvInstance()?.setMultipleProperties(data);
}
} catch (err: NodeMpvError | any) {
} catch (err: any | NodeMpvError) {
mpvLog({ action: `Failed to set properties: ${JSON.stringify(data)}` }, err);
}
});
@@ -199,7 +200,7 @@ ipcMain.handle(
mpvInstance = await createMpv(data);
mpvLog({ action: 'Restarted mpv', toast: 'success' });
setAudioPlayerFallback(false);
} catch (err: NodeMpvError | any) {
} catch (err: any | NodeMpvError) {
mpvLog({ action: 'Failed to restart mpv, falling back to web player' }, err);
setAudioPlayerFallback(true);
}
@@ -215,7 +216,7 @@ ipcMain.handle(
});
mpvInstance = await createMpv(data);
setAudioPlayerFallback(false);
} catch (err: NodeMpvError | any) {
} catch (err: any | NodeMpvError) {
mpvLog({ action: 'Failed to initialize mpv, falling back to web player' }, err);
setAudioPlayerFallback(true);
}
@@ -226,7 +227,7 @@ ipcMain.on('player-quit', async () => {
try {
await getMpvInstance()?.stop();
await quit();
} catch (err: NodeMpvError | any) {
} catch (err: any | NodeMpvError) {
mpvLog({ action: 'Failed to quit mpv' }, err);
} finally {
mpvInstance = null;
@@ -245,7 +246,7 @@ ipcMain.handle('player-clean-up', async () => {
ipcMain.on('player-start', async () => {
try {
await getMpvInstance()?.play();
} catch (err: NodeMpvError | any) {
} catch (err: any | NodeMpvError) {
mpvLog({ action: 'Failed to start mpv playback' }, err);
}
});
@@ -254,7 +255,7 @@ ipcMain.on('player-start', async () => {
ipcMain.on('player-play', async () => {
try {
await getMpvInstance()?.play();
} catch (err: NodeMpvError | any) {
} catch (err: any | NodeMpvError) {
mpvLog({ action: 'Failed to start mpv playback' }, err);
}
});
@@ -263,7 +264,7 @@ ipcMain.on('player-play', async () => {
ipcMain.on('player-pause', async () => {
try {
await getMpvInstance()?.pause();
} catch (err: NodeMpvError | any) {
} catch (err: any | NodeMpvError) {
mpvLog({ action: 'Failed to pause mpv playback' }, err);
}
});
@@ -272,7 +273,7 @@ ipcMain.on('player-pause', async () => {
ipcMain.on('player-stop', async () => {
try {
await getMpvInstance()?.stop();
} catch (err: NodeMpvError | any) {
} catch (err: any | NodeMpvError) {
mpvLog({ action: 'Failed to stop mpv playback' }, err);
}
});
@@ -281,7 +282,7 @@ ipcMain.on('player-stop', async () => {
ipcMain.on('player-next', async () => {
try {
await getMpvInstance()?.next();
} catch (err: NodeMpvError | any) {
} catch (err: any | NodeMpvError) {
mpvLog({ action: 'Failed to go to next track' }, err);
}
});
@@ -290,7 +291,7 @@ ipcMain.on('player-next', async () => {
ipcMain.on('player-previous', async () => {
try {
await getMpvInstance()?.prev();
} catch (err: NodeMpvError | any) {
} catch (err: any | NodeMpvError) {
mpvLog({ action: 'Failed to go to previous track' }, err);
}
});
@@ -299,7 +300,7 @@ ipcMain.on('player-previous', async () => {
ipcMain.on('player-seek', async (_event, time: number) => {
try {
await getMpvInstance()?.seek(time);
} catch (err: NodeMpvError | any) {
} catch (err: any | NodeMpvError) {
mpvLog({ action: `Failed to seek by ${time} seconds` }, err);
}
});
@@ -308,7 +309,7 @@ ipcMain.on('player-seek', async (_event, time: number) => {
ipcMain.on('player-seek-to', async (_event, time: number) => {
try {
await getMpvInstance()?.goToPosition(time);
} catch (err: NodeMpvError | any) {
} catch (err: any | NodeMpvError) {
mpvLog({ action: `Failed to seek to ${time} seconds` }, err);
}
});
@@ -320,7 +321,7 @@ ipcMain.on('player-set-queue', async (_event, current?: string, next?: string, p
await getMpvInstance()?.clearPlaylist();
await getMpvInstance()?.pause();
return;
} catch (err: NodeMpvError | any) {
} catch (err: any | NodeMpvError) {
mpvLog({ action: `Failed to clear play queue` }, err);
}
}
@@ -344,7 +345,7 @@ ipcMain.on('player-set-queue', async (_event, current?: string, next?: string, p
// Only force play if pause is explicitly false
await getMpvInstance()?.play();
}
} catch (err: NodeMpvError | any) {
} catch (err: any | NodeMpvError) {
mpvLog({ action: `Failed to set play queue` }, err);
}
});
@@ -365,7 +366,7 @@ ipcMain.on('player-set-queue-next', async (_event, url?: string) => {
if (url) {
await getMpvInstance()?.load(url, 'append');
}
} catch (err: NodeMpvError | any) {
} catch (err: any | NodeMpvError) {
mpvLog({ action: `Failed to set play queue` }, err);
}
});
@@ -385,7 +386,7 @@ ipcMain.on('player-auto-next', async (_event, url?: string) => {
if (url) {
await getMpvInstance()?.load(url, 'append');
}
} catch (err: NodeMpvError | any) {
} catch (err: any | NodeMpvError) {
mpvLog({ action: `Failed to load next song` }, err);
}
});
@@ -398,7 +399,7 @@ ipcMain.on('player-volume', async (_event, value: number) => {
}
await getMpvInstance()?.volume(value);
} catch (err: NodeMpvError | any) {
} catch (err: any | NodeMpvError) {
mpvLog({ action: `Failed to set volume to ${value}` }, err);
}
});
@@ -407,7 +408,7 @@ ipcMain.on('player-volume', async (_event, value: number) => {
ipcMain.on('player-mute', async (_event, mute: boolean) => {
try {
await getMpvInstance()?.mute(mute);
} catch (err: NodeMpvError | any) {
} catch (err: any | NodeMpvError) {
mpvLog({ action: `Failed to set mute status` }, err);
}
});
@@ -415,7 +416,7 @@ ipcMain.on('player-mute', async (_event, mute: boolean) => {
ipcMain.handle('player-get-time', async (): Promise<number | undefined> => {
try {
return getMpvInstance()?.getTimePosition();
} catch (err: NodeMpvError | any) {
} catch (err: any | NodeMpvError) {
mpvLog({ action: `Failed to get current time` }, err);
return 0;
}
@@ -442,7 +443,7 @@ app.on('before-quit', async (event) => {
event.preventDefault();
await getMpvInstance()?.stop();
await quit();
} catch (err: NodeMpvError | any) {
} catch (err: any | NodeMpvError) {
mpvLog({ action: `Failed to cleanly before-quit` }, err);
} finally {
mpvState = MpvState.DONE;

View File

@@ -1,5 +1,6 @@
/* eslint-disable promise/always-return */
import { BrowserWindow, globalShortcut, systemPreferences } from 'electron';
import { isMacOS } from '../../../utils';
import { store } from '../settings';

View File

@@ -1,32 +1,22 @@
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 manifest from './manifest.json';
import { ClientEvent, ServerEvent } from '../../../../remote/types';
import { PlayerRepeat, PlayerStatus, SongState } from '../../../../renderer/types';
import { getMainWindow } from '../../../main';
import { promises, Stats } from 'fs';
import { readFile } from 'fs/promises';
import { createServer, IncomingMessage, Server, ServerResponse } from 'http';
import { join } from 'path';
import { WebSocket, WebSocketServer, Server as WsServer } from 'ws';
import { deflate, gzip } from 'zlib';
import { getMainWindow } from '../../..';
import { isLinux } from '../../../utils';
import type { QueueSong } from '/@/renderer/api/types';
import manifest from './manifest.json';
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;
@@ -34,6 +24,13 @@ interface MimeType {
js: string;
}
interface RemoteConfig {
enabled: boolean;
password: string;
port: number;
username: string;
}
declare class StatefulWebSocket extends WebSocket {
alive: boolean;
@@ -41,7 +38,7 @@ declare class StatefulWebSocket extends WebSocket {
}
let server: Server | undefined;
let wsServer: WsServer<typeof StatefulWebSocket> | undefined;
let wsServer: undefined | WsServer<typeof StatefulWebSocket>;
const settings: RemoteConfig = {
enabled: false,
@@ -54,14 +51,6 @@ 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) {
@@ -70,6 +59,14 @@ function broadcast(message: ServerEvent): void {
}
}
function send({ client, data, event }: SendData): void {
if (client.readyState === WebSocket.OPEN) {
if (client.alive && client.auth) {
client.send(JSON.stringify({ data, event }));
}
}
}
const shutdownServer = () => {
if (wsServer) {
wsServer.clients.forEach((client) => client.close(4000));
@@ -121,21 +118,17 @@ const getEncoding = (encoding: string | string[]): Encoding => {
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;
function authorize(req: IncomingMessage): boolean {
if (settings.username || settings.password) {
// https://stackoverflow.com/questions/23616371/basic-http-authentication-with-node-and-express-4
res.setHeader('Content-Type', MIME_TYPES[extension]);
res.setHeader('ETag', `"${mtimeMs}"`);
res.setHeader('Cache-Control', 'public');
const authorization = req.headers.authorization?.split(' ')[1] || '';
const [login, password] = Buffer.from(authorization, 'base64').toString().split(':');
if (encoding !== 'none') res.setHeader('Content-Encoding', encoding);
res.end(data);
return login === settings.username && password === settings.password;
}
return true;
}
async function serveFile(
@@ -252,17 +245,21 @@ async function serveFile(
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
function setOk(
res: ServerResponse,
mtimeMs: number,
extension: keyof MimeType,
encoding: Encoding,
data?: Buffer,
) {
res.statusCode = data ? 200 : 304;
const authorization = req.headers.authorization?.split(' ')[1] || '';
const [login, password] = Buffer.from(authorization, 'base64').toString().split(':');
res.setHeader('Content-Type', MIME_TYPES[extension]);
res.setHeader('ETag', `"${mtimeMs}"`);
res.setHeader('Cache-Control', 'public');
return login === settings.username && password === settings.password;
}
return true;
if (encoding !== 'none') res.setHeader('Content-Encoding', encoding);
res.end(data);
}
const enableServer = (config: RemoteConfig): Promise<void> => {
@@ -286,28 +283,28 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
await serveFile(req, 'index', 'html', res);
break;
}
case '/credentials': {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end(req.headers.authorization);
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 '/manifest.json': {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(manifest));
break;
}
case '/credentials': {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end(req.headers.authorization);
case '/remote.css': {
await serveFile(req, 'remote', 'css', res);
break;
}
case '/remote.js': {
await serveFile(req, 'remote', 'js', res);
break;
}
default: {
@@ -371,6 +368,21 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
}
switch (event) {
case 'favorite': {
const { favorite, id } = json;
if (id && id === currentState.song?.id) {
getMainWindow()?.webContents.send('request-favorite', {
favorite,
id,
serverId: currentState.song.serverId,
});
}
break;
}
case 'next': {
getMainWindow()?.webContents.send('renderer-player-next');
break;
}
case 'pause': {
getMainWindow()?.webContents.send('renderer-player-pause');
break;
@@ -379,10 +391,6 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
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;
@@ -421,6 +429,17 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
break;
}
case 'rating': {
const { id, rating } = json;
if (id && id === currentState.song?.id) {
getMainWindow()?.webContents.send('request-rating', {
id,
rating,
serverId: currentState.song.serverId,
});
}
break;
}
case 'repeat': {
getMainWindow()?.webContents.send('renderer-player-toggle-repeat');
break;
@@ -450,28 +469,6 @@ const enableServer = (config: RemoteConfig): Promise<void> => {
}
break;
}
case 'favorite': {
const { favorite, id } = json;
if (id && id === currentState.song?.id) {
getMainWindow()?.webContents.send('request-favorite', {
favorite,
id,
serverId: currentState.song.serverId,
});
}
break;
}
case 'rating': {
const { rating, id } = json;
if (id && id === currentState.song?.id) {
getMainWindow()?.webContents.send('request-rating', {
id,
rating,
serverId: currentState.song.serverId,
});
}
break;
}
case 'position': {
const { position } = json;
if (mprisPlayer) {
@@ -631,12 +628,7 @@ ipcMain.on('update-volume', (_event, volume: number) => {
if (mprisPlayer) {
mprisPlayer.on('loopStatus', (event: string) => {
const repeat =
event === 'Playlist'
? PlayerRepeat.ALL
: event === 'Track'
? PlayerRepeat.ONE
: PlayerRepeat.NONE;
const repeat = event === 'Playlist' ? 'all' : event === 'Track' ? 'one' : 'none';
currentState.repeat = repeat;
broadcast({ data: repeat, event: 'repeat' });

View File

@@ -1,6 +1,7 @@
import type { TitleTheme } from '/@/renderer/types';
import { ipcMain, nativeTheme, safeStorage } from 'electron';
import Store from 'electron-store';
import type { TitleTheme } from '/@/renderer/types';
export const store = new Store();
@@ -12,7 +13,7 @@ ipcMain.on('settings-set', (__event, data: { property: string; value: any }) =>
store.set(`${data.property}`, data.value);
});
ipcMain.handle('password-get', (_event, server: string): string | null => {
ipcMain.handle('password-get', (_event, server: string): null | string => {
if (safeStorage.isEncryptionAvailable()) {
const servers = store.get('server') as Record<string, string> | undefined;

View File

@@ -1,4 +1,3 @@
import './core';
// eslint-disable-next-line import/no-dynamic-require
require(`./${process.platform}`);
// require(`./${process.platform}`)

View File

@@ -1,7 +1,9 @@
import { ipcMain } from 'electron';
import Player from 'mpris-service';
import { PlayerRepeat, PlayerStatus } from '../../../renderer/types';
import { getMainWindow } from '../../main';
import { QueueSong } from '/@/renderer/api/types';
const mprisPlayer = Player({
@@ -124,8 +126,8 @@ ipcMain.on('update-playback', (_event, status: PlayerStatus) => {
const REPEAT_TO_MPRIS: Record<PlayerRepeat, string> = {
[PlayerRepeat.ALL]: 'Playlist',
[PlayerRepeat.ONE]: 'Track',
[PlayerRepeat.NONE]: 'None',
[PlayerRepeat.ONE]: 'Track',
};
ipcMain.on('update-repeat', (_event, arg: PlayerRepeat) => {

View File

@@ -0,0 +1,3 @@
// Dummy file to satisfy the build system
export {};

View File

@@ -1,51 +1,40 @@
/* eslint global-require: off, no-console: off, promise/always-return: off */
/**
* This module executes inside of electron's main process. You can start
* electron renderer process from here and communicate with the other processes
* through IPC.
*
* 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.
*/
import { access, constants, readFile, writeFile } from 'fs';
import path, { join } from 'path';
import { deflate, inflate } from 'zlib';
import { electronApp, is, optimizer } from '@electron-toolkit/utils';
import { constants } from 'buffer';
import {
app,
BrowserWindow,
shell,
ipcMain,
BrowserWindowConstructorOptions,
globalShortcut,
Tray,
ipcMain,
Menu,
nativeImage,
nativeTheme,
BrowserWindowConstructorOptions,
protocol,
net,
protocol,
Rectangle,
screen,
shell,
Tray,
} from 'electron';
import electronLocalShortcut from 'electron-localshortcut';
import log from 'electron-log/main';
import { autoUpdater } from 'electron-updater';
import { access, readFile, writeFile } from 'fs';
import path, { join } from 'path';
import { deflate, inflate } from 'zlib';
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
import { store } from './features/core/settings/index';
import { store } from './features/core/settings';
import MenuBuilder from './menu';
import {
autoUpdaterLogInterface,
createLog,
hotkeyToElectronAccelerator,
isLinux,
isMacOS,
isWindows,
resolveHtmlPath,
createLog,
autoUpdaterLogInterface,
} from './utils';
import './features';
import type { TitleTheme } from '/@/renderer/types';
declare module 'node-mpv';
export default class AppUpdater {
constructor() {
@@ -72,7 +61,7 @@ if (isLinux() && !process.argv.some((a) => a.startsWith('--password-store='))) {
}
let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
let tray: null | Tray = null;
let exitFromTray = false;
let forceQuit = false;
@@ -117,7 +106,7 @@ export const sendToastToRenderer = ({
type,
}: {
message: string;
type: 'success' | 'error' | 'warning' | 'info';
type: 'error' | 'info' | 'success' | 'warning';
}) => {
getMainWindow()?.webContents.send('toast-from-main', {
message,
@@ -208,7 +197,7 @@ const createTray = () => {
tray.setContextMenu(contextMenu);
};
const createWindow = async (first = true) => {
async function createWindow(first = true): Promise<void> {
if (isDevelopment) {
await installExtensions().catch(console.log);
}
@@ -233,6 +222,7 @@ const createWindow = async (first = true) => {
},
};
// Create the browser window.
mainWindow = new BrowserWindow({
autoHideMenuBar: true,
frame: false,
@@ -247,9 +237,8 @@ const createWindow = async (first = true) => {
contextIsolation: true,
devTools: true,
nodeIntegration: true,
preload: app.isPackaged
? path.join(__dirname, 'preload.js')
: path.join(__dirname, '../../.erb/dll/preload.js'),
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
webSecurity: !store.get('ignore_cors'),
},
width: 1440,
@@ -374,11 +363,11 @@ const createWindow = async (first = true) => {
enableMediaKeys(mainWindow);
}
mainWindow.loadURL(resolveHtmlPath('index.html'));
const startWindowMinimized = store.get('window_start_minimized', false) as boolean;
mainWindow.on('ready-to-show', () => {
// mainWindow.show()
if (!mainWindow) {
throw new Error('"mainWindow" is not defined');
}
@@ -404,7 +393,7 @@ const createWindow = async (first = true) => {
mainWindow = null;
});
let saved = false;
const saved = false;
mainWindow.on('close', (event) => {
store.set('bounds', mainWindow?.getNormalBounds());
@@ -484,13 +473,25 @@ const createWindow = async (first = true) => {
});
if (store.get('disable_auto_updates') !== true) {
// eslint-disable-next-line
new AppUpdater();
}
const theme = store.get('theme') as TitleTheme | undefined;
nativeTheme.themeSource = theme || 'dark';
};
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url);
return { action: 'deny' };
});
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']);
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
}
}
app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling,MediaSessionService');
@@ -519,6 +520,8 @@ enum BindingActions {
}
const HOTKEY_ACTIONS: Record<BindingActions, () => void> = {
[BindingActions.GLOBAL_SEARCH]: () => {},
[BindingActions.LOCAL_SEARCH]: () => {},
[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'),
@@ -533,16 +536,14 @@ const HOTKEY_ACTIONS: Record<BindingActions, () => void> = {
[BindingActions.SKIP_FORWARD]: () =>
getMainWindow()?.webContents.send('renderer-player-skip-forward'),
[BindingActions.STOP]: () => getMainWindow()?.webContents.send('renderer-player-stop'),
[BindingActions.TOGGLE_FULLSCREEN_PLAYER]: () => {},
[BindingActions.TOGGLE_QUEUE]: () => {},
[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]: () => {},
[BindingActions.VOLUME_UP]: () =>
getMainWindow()?.webContents.send('renderer-player-volume-up'),
};
ipcMain.on(
@@ -585,7 +586,7 @@ ipcMain.on(
_event,
data: {
message: string;
type: 'debug' | 'verbose' | 'success' | 'error' | 'warning' | 'info';
type: 'debug' | 'error' | 'info' | 'success' | 'verbose' | 'warning';
},
) => {
createLog(data);
@@ -597,7 +598,6 @@ app.on('window-all-closed', () => {
// Respect the OSX convention of having the application in memory even
// after all windows have been closed
if (isMacOS()) {
ipcMain.removeHandler('window-clear-cache');
mainWindow = null;
} else {
app.quit();
@@ -632,22 +632,22 @@ if (!singleInstance) {
app.whenReady()
.then(() => {
protocol.handle('feishin', async (request) => {
const filePath = `file://${request.url.slice('feishin://'.length)}`;
const response = await net.fetch(filePath);
const contentType = response.headers.get('content-type');
// protocol.handle('feishin', async (request) => {
// const filePath = `file://${request.url.slice('feishin://'.length)}`
// const response = await net.fetch(filePath)
// const contentType = response.headers.get('content-type')
if (!contentType || !FONT_HEADERS.includes(contentType)) {
getMainWindow()?.webContents.send('custom-font-error', filePath);
// if (!contentType || !FONT_HEADERS.includes(contentType)) {
// getMainWindow()?.webContents.send('custom-font-error', filePath)
return new Response(null, {
status: 403,
statusText: 'Forbidden',
});
}
// return new Response(null, {
// status: 403,
// statusText: 'Forbidden'
// })
// }
return response;
});
// return response
// })
createWindow();
if (store.get('window_enable_tray', true)) {

View File

@@ -1,4 +1,4 @@
import { app, Menu, shell, BrowserWindow, MenuItemConstructorOptions } from 'electron';
import { app, BrowserWindow, Menu, MenuItemConstructorOptions, shell } from 'electron';
interface DarwinMenuItemConstructorOptions extends MenuItemConstructorOptions {
selector?: string;
@@ -12,37 +12,6 @@ export default class MenuBuilder {
this.mainWindow = mainWindow;
}
buildMenu(): Menu {
if (process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true') {
this.setupDevelopmentEnvironment();
}
const template =
process.platform === 'darwin'
? this.buildDarwinTemplate()
: this.buildDefaultTemplate();
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
return menu;
}
setupDevelopmentEnvironment(): void {
this.mainWindow.webContents.on('context-menu', (_, props) => {
const { x, y } = props;
Menu.buildFromTemplate([
{
click: () => {
this.mainWindow.webContents.inspectElement(x, y);
},
label: 'Inspect element',
},
]).popup({ window: this.mainWindow });
});
}
buildDarwinTemplate(): MenuItemConstructorOptions[] {
const subMenuAbout: DarwinMenuItemConstructorOptions = {
label: 'Electron',
@@ -276,4 +245,35 @@ export default class MenuBuilder {
return templateDefault;
}
buildMenu(): Menu {
if (process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true') {
this.setupDevelopmentEnvironment();
}
const template =
process.platform === 'darwin'
? this.buildDarwinTemplate()
: this.buildDefaultTemplate();
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
return menu;
}
setupDevelopmentEnvironment(): void {
this.mainWindow.webContents.on('context-menu', (_, props) => {
const { x, y } = props;
Menu.buildFromTemplate([
{
click: () => {
this.mainWindow.webContents.inspectElement(x, y);
},
label: 'Inspect element',
},
]).popup({ window: this.mainWindow });
});
}
}

View File

@@ -1,23 +0,0 @@
import { contextBridge } from 'electron';
import { browser } from './preload/browser';
import { discordRpc } from './preload/discord-rpc';
import { ipc } from './preload/ipc';
import { localSettings } from './preload/local-settings';
import { lyrics } from './preload/lyrics';
import { mpris } from './preload/mpris';
import { mpvPlayer, mpvPlayerListener } from './preload/mpv-player';
import { remote } from './preload/remote';
import { utils } from './preload/utils';
contextBridge.exposeInMainWorld('electron', {
browser,
discordRpc,
ipc,
localSettings,
lyrics,
mpris,
mpvPlayer,
mpvPlayerListener,
remote,
utils,
});

View File

@@ -1,8 +1,7 @@
/* eslint import/prefer-default-export: off, import/no-mutable-exports: off */
import log from 'electron-log/main';
import path from 'path';
import process from 'process';
import { URL } from 'url';
import log from 'electron-log/main';
export let resolveHtmlPath: (htmlFileName: string) => string;
@@ -76,7 +75,7 @@ const logColor = {
export const createLog = (data: {
message: string;
type: 'debug' | 'verbose' | 'success' | 'error' | 'warning' | 'info';
type: 'debug' | 'error' | 'info' | 'success' | 'verbose' | 'warning';
}) => {
logMethod[data.type](`%c${data.message}`, `color: ${logColor[data.type]}`);
};

10
src/preload/index.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
import { ElectronAPI } from '@electron-toolkit/preload';
import { PreloadApi } from './index';
declare global {
interface Window {
api: PreloadApi;
electron: ElectronAPI;
}
}

45
src/preload/index.ts Normal file
View File

@@ -0,0 +1,45 @@
import { electronAPI } from '@electron-toolkit/preload';
import { contextBridge } from 'electron';
import { browser } from './browser';
import { discordRpc } from './discord-rpc';
import { ipc } from './ipc';
import { localSettings } from './local-settings';
import { lyrics } from './lyrics';
import { mpris } from './mpris';
import { mpvPlayer, mpvPlayerListener } from './mpv-player';
import { remote } from './remote';
import { utils } from './utils';
// Custom APIs for renderer
const api = {
browser,
discordRpc,
ipc,
localSettings,
lyrics,
mpris,
mpvPlayer,
mpvPlayerListener,
remote,
utils,
};
export type PreloadApi = typeof api;
// Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise
// just add to the DOM global.
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI);
contextBridge.exposeInMainWorld('api', api);
} catch (error) {
console.error(error);
}
} else {
// @ts-ignore (define in dts)
window.electron = electronAPI;
// @ts-ignore (define in dts)
window.api = api;
}

View File

@@ -1,12 +1,11 @@
import { IpcRendererEvent, ipcRenderer, webFrame } from 'electron';
import { ipcRenderer, IpcRendererEvent, webFrame } from 'electron';
import Store from 'electron-store';
import { toServerType, type TitleTheme } from '/@/renderer/types';
const store = new Store();
const set = (
property: string,
value: string | Record<string, unknown> | boolean | string[] | undefined,
value: boolean | Record<string, unknown> | string | string[] | undefined,
) => {
if (value === undefined) {
store.delete(property);
@@ -32,7 +31,7 @@ const disableMediaKeys = () => {
ipcRenderer.send('global-media-keys-disable');
};
const passwordGet = async (server: string): Promise<string | null> => {
const passwordGet = async (server: string): Promise<null | string> => {
return ipcRenderer.invoke('password-get', server);
};
@@ -56,6 +55,19 @@ const themeSet = (theme: TitleTheme): void => {
ipcRenderer.send('theme-set', theme);
};
export const toServerType = (value?: string): null | string => {
switch (value?.toLowerCase()) {
case 'jellyfin':
return 'jellyfin';
case 'navidrome':
return 'navidrome';
case 'subsonic':
return 'subsonic';
default:
return null;
}
};
const SERVER_TYPE = toServerType(process.env.SERVER_TYPE);
const env = {

View File

@@ -1,11 +1,11 @@
import { ipcRenderer } from 'electron';
import {
InternetProviderLyricSearchResponse,
LyricGetQuery,
LyricSearchQuery,
LyricSource,
QueueSong,
} from '/@/renderer/api/types';
} from '../main/features/core/lyrics';
const getRemoteLyricsBySong = (song: QueueSong) => {
const result = ipcRenderer.invoke('lyric-by-song', song);

View File

@@ -1,5 +1,4 @@
import { IpcRendererEvent, ipcRenderer } from 'electron';
import type { PlayerRepeat } from '/@/renderer/types';
import { ipcRenderer, IpcRendererEvent } from 'electron';
const updatePosition = (timeSec: number) => {
ipcRenderer.send('mpris-update-position', timeSec);

View File

@@ -1,5 +1,4 @@
import { ipcRenderer, IpcRendererEvent } from 'electron';
import { PlayerData } from '/@/renderer/store';
const initialize = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
return ipcRenderer.invoke('player-initialize', data);
@@ -187,8 +186,8 @@ export const mpvPlayerListener = {
rendererNext,
rendererPause,
rendererPlay,
rendererPlayPause,
rendererPlayerFallback,
rendererPlayPause,
rendererPrevious,
rendererQuit,
rendererSkipBackward,

View File

@@ -1,6 +1,4 @@
import { IpcRendererEvent, ipcRenderer } from 'electron';
import { QueueSong } from '/@/renderer/api/types';
import { PlayerStatus } from '/@/renderer/types';
import { ipcRenderer, IpcRendererEvent } from 'electron';
const requestFavorite = (
cb: (
@@ -29,12 +27,12 @@ const requestVolume = (cb: (event: IpcRendererEvent, data: { volume: number }) =
ipcRenderer.on('request-volume', cb);
};
const setRemoteEnabled = (enabled: boolean): Promise<string | null> => {
const setRemoteEnabled = (enabled: boolean): Promise<null | string> => {
const result = ipcRenderer.invoke('remote-enable', enabled);
return result;
};
const setRemotePort = (port: number): Promise<string | null> => {
const setRemotePort = (port: number): Promise<null | string> => {
const result = ipcRenderer.invoke('remote-port', port);
return result;
};
@@ -56,7 +54,7 @@ const updateSetting = (
port: number,
username: string,
password: string,
): Promise<string | null> => {
): Promise<null | string> => {
return ipcRenderer.invoke('remote-settings', enabled, port, username, password);
};

View File

@@ -1,6 +1,6 @@
import { IpcRendererEvent, ipcRenderer } from 'electron';
import { isMacOS, isWindows, isLinux } from '../utils';
import { PlayerState } from '/@/renderer/store';
import { ipcRenderer, IpcRendererEvent } from 'electron';
import { isLinux, isMacOS, isWindows } from '../main/utils';
const saveQueue = (data: Record<string, any>) => {
ipcRenderer.send('player-save-queue', data);
@@ -18,7 +18,7 @@ const onSaveQueue = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-save-queue', cb);
};
const onRestoreQueue = (cb: (event: IpcRendererEvent, data: Partial<PlayerState>) => void) => {
const onRestoreQueue = (cb: (event: IpcRendererEvent, data: Partial<any>) => void) => {
ipcRenderer.on('renderer-restore-queue', cb);
};
@@ -29,7 +29,7 @@ const playerErrorListener = (cb: (event: IpcRendererEvent, data: { code: number
const mainMessageListener = (
cb: (
event: IpcRendererEvent,
data: { message: string; type: 'success' | 'error' | 'warning' | 'info' },
data: { message: string; type: 'error' | 'info' | 'success' | 'warning' },
) => void,
) => {
ipcRenderer.on('toast-from-main', cb);
@@ -40,7 +40,7 @@ const logger = (
event: IpcRendererEvent,
data: {
message: string;
type: 'debug' | 'verbose' | 'error' | 'warning' | 'info';
type: 'debug' | 'error' | 'info' | 'verbose' | 'warning';
},
) => void,
) => {

View File

@@ -1,10 +1,11 @@
import { useAuthStore } from '/@/renderer/store';
import { toast } from '/@/renderer/components/toast/index';
import type { ServerType, ControllerEndpoint, AuthenticationResponse } from '/@/renderer/api/types';
import type { AuthenticationResponse, ControllerEndpoint, ServerType } from '/@/renderer/api/types';
import i18n from '/@/i18n/i18n';
import { JellyfinController } from '/@/renderer/api/jellyfin/jellyfin-controller';
import { NavidromeController } from '/@/renderer/api/navidrome/navidrome-controller';
import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller';
import { JellyfinController } from '/@/renderer/api/jellyfin/jellyfin-controller';
import i18n from '/@/i18n/i18n';
import { toast } from '/@/renderer/components/toast/index';
import { useAuthStore } from '/@/renderer/store';
type ApiController = {
jellyfin: ControllerEndpoint;

View File

@@ -1,97 +1,56 @@
export type JFBasePaginatedResponse = {
StartIndex: number;
TotalRecordCount: number;
};
export interface JFMusicFolderListResponse extends JFBasePaginatedResponse {
Items: JFMusicFolder[];
export enum JFAlbumArtistListSort {
ALBUM = 'Album,SortName',
DURATION = 'Runtime,AlbumArtist,Album,SortName',
NAME = 'SortName,Name',
RANDOM = 'Random,SortName',
RECENTLY_ADDED = 'DateCreated,SortName',
RELEASE_DATE = 'PremiereDate,AlbumArtist,Album,SortName',
}
export type JFMusicFolderList = JFMusicFolder[];
export interface JFGenreListResponse extends JFBasePaginatedResponse {
Items: JFGenre[];
export enum JFAlbumListSort {
ALBUM_ARTIST = 'AlbumArtist,SortName',
COMMUNITY_RATING = 'CommunityRating,SortName',
CRITIC_RATING = 'CriticRating,SortName',
NAME = 'SortName',
PLAY_COUNT = 'PlayCount',
RANDOM = 'Random,SortName',
RECENTLY_ADDED = 'DateCreated,SortName',
RELEASE_DATE = 'ProductionYear,PremiereDate,SortName',
}
export type JFGenreList = JFGenreListResponse;
export enum JFArtistListSort {
ALBUM = 'Album,SortName',
DURATION = 'Runtime,AlbumArtist,Album,SortName',
NAME = 'SortName,Name',
RANDOM = 'Random,SortName',
RECENTLY_ADDED = 'DateCreated,SortName',
RELEASE_DATE = 'PremiereDate,AlbumArtist,Album,SortName',
}
export enum JFCollectionType {
MUSIC = 'music',
PLAYLISTS = 'playlists',
}
export enum JFExternalType {
MUSICBRAINZ = 'MusicBrainz',
THEAUDIODB = 'TheAudioDb',
}
export enum JFGenreListSort {
NAME = 'SortName',
}
export type JFAlbumArtistDetailResponse = JFAlbumArtist;
export type JFAlbumArtistDetail = JFAlbumArtistDetailResponse;
export interface JFAlbumArtistListResponse extends JFBasePaginatedResponse {
Items: JFAlbumArtist[];
export enum JFImageType {
LOGO = 'Logo',
PRIMARY = 'Primary',
}
export type JFAlbumArtistList = {
items: JFAlbumArtist[];
startIndex: number;
totalRecordCount: number;
};
export interface JFArtistListResponse extends JFBasePaginatedResponse {
Items: JFAlbumArtist[];
export enum JFItemType {
AUDIO = 'Audio',
MUSICALBUM = 'MusicAlbum',
}
export type JFArtistList = JFArtistListResponse;
export interface JFAlbumListResponse extends JFBasePaginatedResponse {
Items: JFAlbum[];
}
export type JFAlbumList = {
items: JFAlbum[];
startIndex: number;
totalRecordCount: number;
};
export type JFAlbumDetailResponse = JFAlbum;
export type JFAlbumDetail = JFAlbum & { songs?: JFSong[] };
export interface JFSongListResponse extends JFBasePaginatedResponse {
Items: JFSong[];
}
export type JFSongList = {
items: JFSong[];
startIndex: number;
totalRecordCount: number;
};
export type JFAddToPlaylistResponse = {
added: number;
};
export type JFAddToPlaylistParams = {
ids: string[];
userId: string;
};
export type JFAddToPlaylist = null;
export type JFRemoveFromPlaylistResponse = null;
export type JFRemoveFromPlaylistParams = {
entryIds: string[];
};
export type JFRemoveFromPlaylist = null;
export interface JFPlaylistListResponse extends JFBasePaginatedResponse {
Items: JFPlaylist[];
}
export type JFPlaylistList = {
items: JFPlaylist[];
startIndex: number;
totalRecordCount: number;
};
export enum JFPlaylistListSort {
ALBUM_ARTIST = 'AlbumArtist,SortName',
DURATION = 'Runtime',
@@ -100,124 +59,34 @@ export enum JFPlaylistListSort {
SONG_COUNT = 'ChildCount',
}
export type JFPlaylistDetailResponse = JFPlaylist;
export enum JFSongListSort {
ALBUM = 'Album,SortName',
ALBUM_ARTIST = 'AlbumArtist,Album,SortName',
ARTIST = 'Artist,Album,SortName',
COMMUNITY_RATING = 'CommunityRating,SortName',
DURATION = 'Runtime,AlbumArtist,Album,SortName',
NAME = 'Name',
PLAY_COUNT = 'PlayCount,SortName',
RANDOM = 'Random,SortName',
RECENTLY_ADDED = 'DateCreated,SortName',
RECENTLY_PLAYED = 'DatePlayed,SortName',
RELEASE_DATE = 'PremiereDate,AlbumArtist,Album,SortName',
}
export type JFPlaylistDetail = JFPlaylist & { songs?: JFSong[] };
export enum JFSortOrder {
ASC = 'Ascending',
DESC = 'Descending',
}
export type JFPlaylist = {
BackdropImageTags: string[];
ChannelId: null;
ChildCount?: number;
DateCreated: string;
GenreItems: GenreItem[];
Genres: string[];
Id: string;
ImageBlurHashes: ImageBlurHashes;
ImageTags: ImageTags;
IsFolder: boolean;
LocationType: string;
MediaType: string;
Name: string;
Overview?: string;
RunTimeTicks: number;
ServerId: string;
Type: string;
UserData: UserData;
export type JFAddToPlaylist = null;
export type JFAddToPlaylistParams = {
ids: string[];
userId: string;
};
export type JFRequestParams = {
albumArtistIds?: string;
artistIds?: string;
enableImageTypes?: string;
enableTotalRecordCount?: boolean;
enableUserData?: boolean;
excludeItemTypes?: string;
fields?: string;
imageTypeLimit?: number;
includeItemTypes?: string;
isFavorite?: boolean;
limit?: number;
parentId?: string;
recursive?: boolean;
searchTerm?: string;
sortBy?: string;
sortOrder?: 'Ascending' | 'Descending';
startIndex?: number;
userId?: string;
};
export type JFMusicFolder = {
BackdropImageTags: string[];
ChannelId: null;
CollectionType: string;
Id: string;
ImageBlurHashes: ImageBlurHashes;
ImageTags: ImageTags;
IsFolder: boolean;
LocationType: string;
Name: string;
ServerId: string;
Type: string;
UserData: UserData;
};
export type JFGenre = {
BackdropImageTags: any[];
ChannelId: null;
Id: string;
ImageBlurHashes: any;
ImageTags: ImageTags;
LocationType: string;
Name: string;
ServerId: string;
Type: string;
};
export type JFAlbumArtist = {
BackdropImageTags: string[];
ChannelId: null;
DateCreated: string;
ExternalUrls: ExternalURL[];
GenreItems: GenreItem[];
Genres: string[];
Id: string;
ImageBlurHashes: any;
ImageTags: ImageTags;
LocationType: string;
Name: string;
Overview?: string;
RunTimeTicks: number;
ServerId: string;
Type: string;
UserData: {
IsFavorite: boolean;
Key: string;
PlayCount: number;
PlaybackPositionTicks: number;
Played: boolean;
};
} & {
similarArtists: {
items: JFAlbumArtist[];
};
};
export type JFArtist = {
BackdropImageTags: string[];
ChannelId: null;
DateCreated: string;
ExternalUrls: ExternalURL[];
GenreItems: GenreItem[];
Genres: string[];
Id: string;
ImageBlurHashes: any;
ImageTags: string[];
LocationType: string;
Name: string;
Overview?: string;
RunTimeTicks: number;
ServerId: string;
Type: string;
export type JFAddToPlaylistResponse = {
added: number;
};
export type JFAlbum = {
@@ -251,6 +120,244 @@ export type JFAlbum = {
songs?: JFSong[];
};
export type JFAlbumArtist = {
BackdropImageTags: string[];
ChannelId: null;
DateCreated: string;
ExternalUrls: ExternalURL[];
GenreItems: GenreItem[];
Genres: string[];
Id: string;
ImageBlurHashes: any;
ImageTags: ImageTags;
LocationType: string;
Name: string;
Overview?: string;
RunTimeTicks: number;
ServerId: string;
Type: string;
UserData: {
IsFavorite: boolean;
Key: string;
PlaybackPositionTicks: number;
PlayCount: number;
Played: boolean;
};
} & {
similarArtists: {
items: JFAlbumArtist[];
};
};
export type JFAlbumArtistDetail = JFAlbumArtistDetailResponse;
export type JFAlbumArtistDetailResponse = JFAlbumArtist;
export type JFAlbumArtistList = {
items: JFAlbumArtist[];
startIndex: number;
totalRecordCount: number;
};
export type JFAlbumArtistListParams = JFBaseParams &
JFPaginationParams & {
filters?: string;
genres?: string;
sortBy?: JFAlbumArtistListSort;
years?: string;
};
export interface JFAlbumArtistListResponse extends JFBasePaginatedResponse {
Items: JFAlbumArtist[];
}
export type JFAlbumDetail = JFAlbum & { songs?: JFSong[] };
export type JFAlbumDetailResponse = JFAlbum;
export type JFAlbumList = {
items: JFAlbum[];
startIndex: number;
totalRecordCount: number;
};
export type JFAlbumListParams = JFBaseParams &
JFPaginationParams & {
albumArtistIds?: string;
artistIds?: string;
filters?: string;
genreIds?: string;
genres?: string;
includeItemTypes: 'MusicAlbum';
isFavorite?: boolean;
searchTerm?: string;
sortBy?: JFAlbumListSort;
tags?: string;
years?: string;
};
export interface JFAlbumListResponse extends JFBasePaginatedResponse {
Items: JFAlbum[];
}
export type JFArtist = {
BackdropImageTags: string[];
ChannelId: null;
DateCreated: string;
ExternalUrls: ExternalURL[];
GenreItems: GenreItem[];
Genres: string[];
Id: string;
ImageBlurHashes: any;
ImageTags: string[];
LocationType: string;
Name: string;
Overview?: string;
RunTimeTicks: number;
ServerId: string;
Type: string;
};
export type JFArtistList = JFArtistListResponse;
export type JFArtistListParams = JFBaseParams &
JFPaginationParams & {
filters?: string;
genres?: string;
sortBy?: JFArtistListSort;
years?: string;
};
export interface JFArtistListResponse extends JFBasePaginatedResponse {
Items: JFAlbumArtist[];
}
export interface JFAuthenticate {
AccessToken: string;
ServerId: string;
SessionInfo: SessionInfo;
User: User;
}
export type JFBasePaginatedResponse = {
StartIndex: number;
TotalRecordCount: number;
};
export type JFCreatePlaylist = JFCreatePlaylistResponse;
export type JFCreatePlaylistResponse = {
Id: string;
};
export type JFGenericItem = {
Id: string;
Name: string;
};
export type JFGenre = {
BackdropImageTags: any[];
ChannelId: null;
Id: string;
ImageBlurHashes: any;
ImageTags: ImageTags;
LocationType: string;
Name: string;
ServerId: string;
Type: string;
};
export type JFGenreList = JFGenreListResponse;
export interface JFGenreListResponse extends JFBasePaginatedResponse {
Items: JFGenre[];
}
export type JFMusicFolder = {
BackdropImageTags: string[];
ChannelId: null;
CollectionType: string;
Id: string;
ImageBlurHashes: ImageBlurHashes;
ImageTags: ImageTags;
IsFolder: boolean;
LocationType: string;
Name: string;
ServerId: string;
Type: string;
UserData: UserData;
};
export type JFMusicFolderList = JFMusicFolder[];
export interface JFMusicFolderListResponse extends JFBasePaginatedResponse {
Items: JFMusicFolder[];
}
export type JFPlaylist = {
BackdropImageTags: string[];
ChannelId: null;
ChildCount?: number;
DateCreated: string;
GenreItems: GenreItem[];
Genres: string[];
Id: string;
ImageBlurHashes: ImageBlurHashes;
ImageTags: ImageTags;
IsFolder: boolean;
LocationType: string;
MediaType: string;
Name: string;
Overview?: string;
RunTimeTicks: number;
ServerId: string;
Type: string;
UserData: UserData;
};
export type JFPlaylistDetail = JFPlaylist & { songs?: JFSong[] };
export type JFPlaylistDetailResponse = JFPlaylist;
export type JFPlaylistList = {
items: JFPlaylist[];
startIndex: number;
totalRecordCount: number;
};
export interface JFPlaylistListResponse extends JFBasePaginatedResponse {
Items: JFPlaylist[];
}
export type JFRemoveFromPlaylist = null;
export type JFRemoveFromPlaylistParams = {
entryIds: string[];
};
export type JFRemoveFromPlaylistResponse = null;
export type JFRequestParams = {
albumArtistIds?: string;
artistIds?: string;
enableImageTypes?: string;
enableTotalRecordCount?: boolean;
enableUserData?: boolean;
excludeItemTypes?: string;
fields?: string;
imageTypeLimit?: number;
includeItemTypes?: string;
isFavorite?: boolean;
limit?: number;
parentId?: string;
recursive?: boolean;
searchTerm?: string;
sortBy?: string;
sortOrder?: 'Ascending' | 'Descending';
startIndex?: number;
userId?: string;
};
export type JFSong = {
Album: string;
AlbumArtist: string;
@@ -285,23 +392,56 @@ export type JFSong = {
UserData?: UserData;
};
type ImageBlurHashes = {
Backdrop?: any;
Logo?: any;
Primary?: any;
export type JFSongList = {
items: JFSong[];
startIndex: number;
totalRecordCount: number;
};
type ImageTags = {
Logo?: string;
Primary?: string;
export type JFSongListParams = JFBaseParams &
JFPaginationParams & {
albumArtistIds?: string;
albumIds?: string;
artistIds?: string;
contributingArtistIds?: string;
filters?: string;
genreIds?: string;
genres?: string;
ids?: string;
includeItemTypes: 'Audio';
searchTerm?: string;
sortBy?: JFSongListSort;
years?: string;
};
export interface JFSongListResponse extends JFBasePaginatedResponse {
Items: JFSong[];
}
type Capabilities = {
PlayableMediaTypes: any[];
SupportedCommands: any[];
SupportsContentUploading: boolean;
SupportsMediaControl: boolean;
SupportsPersistentIdentifier: boolean;
SupportsSync: boolean;
};
type UserData = {
IsFavorite: boolean;
Key: string;
PlayCount: number;
PlaybackPositionTicks: number;
Played: boolean;
type Configuration = {
DisplayCollectionsView: boolean;
DisplayMissingEpisodes: boolean;
EnableLocalPassword: boolean;
EnableNextEpisodeAutoPlay: boolean;
GroupedFolders: any[];
HidePlayedInLatest: boolean;
LatestItemsExcludes: any[];
MyMediaExcludes: any[];
OrderedViews: any[];
PlayDefaultAudioTrack: boolean;
RememberAudioSelections: boolean;
RememberSubtitleSelections: boolean;
SubtitleLanguagePreference: string;
SubtitleMode: string;
};
type ExternalURL = {
@@ -314,9 +454,32 @@ type GenreItem = {
Name: string;
};
export type JFGenericItem = {
Id: string;
Name: string;
type ImageBlurHashes = {
Backdrop?: any;
Logo?: any;
Primary?: any;
};
type ImageTags = {
Logo?: string;
Primary?: string;
};
type JFBaseParams = {
enableImageTypes?: JFImageType[];
fields?: string;
imageTypeLimit?: number;
parentId?: string;
recursive?: boolean;
searchTerm?: string;
userId?: string;
};
type JFPaginationParams = {
limit?: number;
nameStartsWith?: string;
sortOrder?: JFSortOrder;
startIndex?: number;
};
type MediaSources = {
@@ -380,32 +543,53 @@ type MediaStream = {
Width?: number;
};
export enum JFExternalType {
MUSICBRAINZ = 'MusicBrainz',
THEAUDIODB = 'TheAudioDb',
}
type PlayState = {
CanSeek: boolean;
IsMuted: boolean;
IsPaused: boolean;
RepeatMode: string;
};
export enum JFImageType {
LOGO = 'Logo',
PRIMARY = 'Primary',
}
export enum JFItemType {
AUDIO = 'Audio',
MUSICALBUM = 'MusicAlbum',
}
export enum JFCollectionType {
MUSIC = 'music',
PLAYLISTS = 'playlists',
}
export interface JFAuthenticate {
AccessToken: string;
ServerId: string;
SessionInfo: SessionInfo;
User: User;
}
type Policy = {
AccessSchedules: any[];
AuthenticationProviderId: string;
BlockedChannels: any[];
BlockedMediaFolders: any[];
BlockedTags: any[];
BlockUnratedItems: any[];
EnableAllChannels: boolean;
EnableAllDevices: boolean;
EnableAllFolders: boolean;
EnableAudioPlaybackTranscoding: boolean;
EnableContentDeletion: boolean;
EnableContentDeletionFromFolders: any[];
EnableContentDownloading: boolean;
EnabledChannels: any[];
EnabledDevices: any[];
EnabledFolders: any[];
EnableLiveTvAccess: boolean;
EnableLiveTvManagement: boolean;
EnableMediaConversion: boolean;
EnableMediaPlayback: boolean;
EnablePlaybackRemuxing: boolean;
EnablePublicSharing: boolean;
EnableRemoteAccess: boolean;
EnableRemoteControlOfOtherUsers: boolean;
EnableSharedDeviceControl: boolean;
EnableSyncTranscoding: boolean;
EnableUserPreferenceAccess: boolean;
EnableVideoPlaybackTranscoding: boolean;
ForceRemoteSourceTranscoding: boolean;
InvalidLoginAttemptCount: number;
IsAdministrator: boolean;
IsDisabled: boolean;
IsHidden: boolean;
LoginAttemptsBeforeLockout: number;
MaxActiveSessions: number;
PasswordResetProviderId: string;
RemoteClientBitrateLimit: number;
SyncPlayAccess: string;
};
type SessionInfo = {
AdditionalUsers: any[];
@@ -421,8 +605,8 @@ type SessionInfo = {
LastPlaybackCheckIn: string;
NowPlayingQueue: any[];
NowPlayingQueueFullItems: any[];
PlayState: PlayState;
PlayableMediaTypes: any[];
PlayState: PlayState;
RemoteEndPoint: string;
ServerId: string;
SupportedCommands: any[];
@@ -432,22 +616,6 @@ type SessionInfo = {
UserName: string;
};
type Capabilities = {
PlayableMediaTypes: any[];
SupportedCommands: any[];
SupportsContentUploading: boolean;
SupportsMediaControl: boolean;
SupportsPersistentIdentifier: boolean;
SupportsSync: boolean;
};
type PlayState = {
CanSeek: boolean;
IsMuted: boolean;
IsPaused: boolean;
RepeatMode: string;
};
type User = {
Configuration: Configuration;
EnableAutoLogin: boolean;
@@ -462,178 +630,10 @@ type User = {
ServerId: string;
};
type Configuration = {
DisplayCollectionsView: boolean;
DisplayMissingEpisodes: boolean;
EnableLocalPassword: boolean;
EnableNextEpisodeAutoPlay: boolean;
GroupedFolders: any[];
HidePlayedInLatest: boolean;
LatestItemsExcludes: any[];
MyMediaExcludes: any[];
OrderedViews: any[];
PlayDefaultAudioTrack: boolean;
RememberAudioSelections: boolean;
RememberSubtitleSelections: boolean;
SubtitleLanguagePreference: string;
SubtitleMode: string;
type UserData = {
IsFavorite: boolean;
Key: string;
PlaybackPositionTicks: number;
PlayCount: number;
Played: boolean;
};
type Policy = {
AccessSchedules: any[];
AuthenticationProviderId: string;
BlockUnratedItems: any[];
BlockedChannels: any[];
BlockedMediaFolders: any[];
BlockedTags: any[];
EnableAllChannels: boolean;
EnableAllDevices: boolean;
EnableAllFolders: boolean;
EnableAudioPlaybackTranscoding: boolean;
EnableContentDeletion: boolean;
EnableContentDeletionFromFolders: any[];
EnableContentDownloading: boolean;
EnableLiveTvAccess: boolean;
EnableLiveTvManagement: boolean;
EnableMediaConversion: boolean;
EnableMediaPlayback: boolean;
EnablePlaybackRemuxing: boolean;
EnablePublicSharing: boolean;
EnableRemoteAccess: boolean;
EnableRemoteControlOfOtherUsers: boolean;
EnableSharedDeviceControl: boolean;
EnableSyncTranscoding: boolean;
EnableUserPreferenceAccess: boolean;
EnableVideoPlaybackTranscoding: boolean;
EnabledChannels: any[];
EnabledDevices: any[];
EnabledFolders: any[];
ForceRemoteSourceTranscoding: boolean;
InvalidLoginAttemptCount: number;
IsAdministrator: boolean;
IsDisabled: boolean;
IsHidden: boolean;
LoginAttemptsBeforeLockout: number;
MaxActiveSessions: number;
PasswordResetProviderId: string;
RemoteClientBitrateLimit: number;
SyncPlayAccess: string;
};
type JFBaseParams = {
enableImageTypes?: JFImageType[];
fields?: string;
imageTypeLimit?: number;
parentId?: string;
recursive?: boolean;
searchTerm?: string;
userId?: string;
};
type JFPaginationParams = {
limit?: number;
nameStartsWith?: string;
sortOrder?: JFSortOrder;
startIndex?: number;
};
export enum JFSortOrder {
ASC = 'Ascending',
DESC = 'Descending',
}
export enum JFAlbumListSort {
ALBUM_ARTIST = 'AlbumArtist,SortName',
COMMUNITY_RATING = 'CommunityRating,SortName',
CRITIC_RATING = 'CriticRating,SortName',
NAME = 'SortName',
PLAY_COUNT = 'PlayCount',
RANDOM = 'Random,SortName',
RECENTLY_ADDED = 'DateCreated,SortName',
RELEASE_DATE = 'ProductionYear,PremiereDate,SortName',
}
export type JFAlbumListParams = {
albumArtistIds?: string;
artistIds?: string;
filters?: string;
genreIds?: string;
genres?: string;
includeItemTypes: 'MusicAlbum';
isFavorite?: boolean;
searchTerm?: string;
sortBy?: JFAlbumListSort;
tags?: string;
years?: string;
} & JFBaseParams &
JFPaginationParams;
export enum JFSongListSort {
ALBUM = 'Album,SortName',
ALBUM_ARTIST = 'AlbumArtist,Album,SortName',
ARTIST = 'Artist,Album,SortName',
COMMUNITY_RATING = 'CommunityRating,SortName',
DURATION = 'Runtime,AlbumArtist,Album,SortName',
NAME = 'Name',
PLAY_COUNT = 'PlayCount,SortName',
RANDOM = 'Random,SortName',
RECENTLY_ADDED = 'DateCreated,SortName',
RECENTLY_PLAYED = 'DatePlayed,SortName',
RELEASE_DATE = 'PremiereDate,AlbumArtist,Album,SortName',
}
export type JFSongListParams = {
albumArtistIds?: string;
albumIds?: string;
artistIds?: string;
contributingArtistIds?: string;
filters?: string;
genreIds?: string;
genres?: string;
ids?: string;
includeItemTypes: 'Audio';
searchTerm?: string;
sortBy?: JFSongListSort;
years?: string;
} & JFBaseParams &
JFPaginationParams;
export enum JFAlbumArtistListSort {
ALBUM = 'Album,SortName',
DURATION = 'Runtime,AlbumArtist,Album,SortName',
NAME = 'SortName,Name',
RANDOM = 'Random,SortName',
RECENTLY_ADDED = 'DateCreated,SortName',
RELEASE_DATE = 'PremiereDate,AlbumArtist,Album,SortName',
}
export type JFAlbumArtistListParams = {
filters?: string;
genres?: string;
sortBy?: JFAlbumArtistListSort;
years?: string;
} & JFBaseParams &
JFPaginationParams;
export enum JFArtistListSort {
ALBUM = 'Album,SortName',
DURATION = 'Runtime,AlbumArtist,Album,SortName',
NAME = 'SortName,Name',
RANDOM = 'Random,SortName',
RECENTLY_ADDED = 'DateCreated,SortName',
RELEASE_DATE = 'PremiereDate,AlbumArtist,Album,SortName',
}
export type JFArtistListParams = {
filters?: string;
genres?: string;
sortBy?: JFArtistListSort;
years?: string;
} & JFBaseParams &
JFPaginationParams;
export type JFCreatePlaylistResponse = {
Id: string;
};
export type JFCreatePlaylist = JFCreatePlaylistResponse;

View File

@@ -1,15 +1,17 @@
import { useAuthStore } from '/@/renderer/store';
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
import { initClient, initContract } from '@ts-rest/core';
import axios, { AxiosError, AxiosResponse, isAxiosError, Method } from 'axios';
import qs from 'qs';
import { ServerListItem } from '/@/renderer/api/types';
import omitBy from 'lodash/omitBy';
import qs from 'qs';
import { z } from 'zod';
import { authenticationFailure, getClientType } from '/@/renderer/api/utils';
import i18n from '/@/i18n/i18n';
import packageJson from '../../../../package.json';
import i18n from '/@/i18n/i18n';
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
import { ServerListItem } from '/@/renderer/api/types';
import { authenticationFailure, getClientType } from '/@/renderer/api/utils';
import { useAuthStore } from '/@/renderer/store';
const c = initContract();
export const contract = c.router({
@@ -356,14 +358,14 @@ export const createAuthHeader = (): string => {
};
export const jfApiClient = (args: {
server: ServerListItem | null;
server: null | ServerListItem;
signal?: AbortSignal;
url?: string;
}) => {
const { server, url, signal } = args;
const { server, signal, url } = args;
return initClient(contract, {
api: async ({ path, method, headers, body }) => {
api: async ({ body, headers, method, path }) => {
let baseUrl: string | undefined;
let token: string | undefined;
@@ -395,7 +397,7 @@ export const jfApiClient = (args: {
headers: result.headers as any,
status: result.status,
};
} catch (e: Error | AxiosError | any) {
} catch (e: any | AxiosError | Error) {
if (isAxiosError(e)) {
if (e.code === 'ERR_NETWORK') {
throw new Error(

View File

@@ -1,23 +1,25 @@
import chunk from 'lodash/chunk';
import { z } from 'zod';
import { jfNormalize } from './jellyfin-normalize';
import { ServerFeature } from '/@/renderer/api/features-types';
import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types';
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
import {
albumArtistListSortMap,
sortOrderMap,
albumListSortMap,
songListSortMap,
playlistListSortMap,
genreListSortMap,
Song,
Played,
ControllerEndpoint,
genreListSortMap,
LibraryItem,
Played,
playlistListSortMap,
Song,
songListSortMap,
sortOrderMap,
} from '/@/renderer/api/types';
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
import { jfNormalize } from './jellyfin-normalize';
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
import { z } from 'zod';
import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types';
import { ServerFeature } from '/@/renderer/api/features-types';
import { VersionInfo, getFeatures, hasFeature } from '/@/renderer/api/utils';
import chunk from 'lodash/chunk';
import { getFeatures, hasFeature, VersionInfo } from '/@/renderer/api/utils';
const formatCommaDelimitedString = (value: string[]) => {
return value.join(',');
@@ -41,7 +43,7 @@ const VERSION_INFO: VersionInfo = [
export const JellyfinController: ControllerEndpoint = {
addToPlaylist: async (args) => {
const { query, body, apiClientProps } = args;
const { apiClientProps, body, query } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
@@ -89,7 +91,7 @@ export const JellyfinController: ControllerEndpoint = {
};
},
createFavorite: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
@@ -108,7 +110,7 @@ export const JellyfinController: ControllerEndpoint = {
return null;
},
createPlaylist: async (args) => {
const { body, apiClientProps } = args;
const { apiClientProps, body } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
@@ -132,7 +134,7 @@ export const JellyfinController: ControllerEndpoint = {
};
},
deleteFavorite: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
@@ -151,7 +153,7 @@ export const JellyfinController: ControllerEndpoint = {
return null;
},
deletePlaylist: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const res = await jfApiClient(apiClientProps).deletePlaylist({
params: {
@@ -166,7 +168,7 @@ export const JellyfinController: ControllerEndpoint = {
return null;
},
getAlbumArtistDetail: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
@@ -201,7 +203,7 @@ export const JellyfinController: ControllerEndpoint = {
);
},
getAlbumArtistList: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const res = await jfApiClient(apiClientProps).getAlbumArtistList({
query: {
@@ -236,7 +238,7 @@ export const JellyfinController: ControllerEndpoint = {
query: { ...query, limit: 1, startIndex: 0 },
}).then((result) => result!.totalRecordCount!),
getAlbumDetail: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
@@ -274,7 +276,7 @@ export const JellyfinController: ControllerEndpoint = {
);
},
getAlbumList: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
@@ -334,7 +336,7 @@ export const JellyfinController: ControllerEndpoint = {
query: { ...query, limit: 1, startIndex: 0 },
}).then((result) => result!.totalRecordCount!),
getArtistList: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const res = await jfApiClient(apiClientProps).getArtistList({
query: {
@@ -404,7 +406,7 @@ export const JellyfinController: ControllerEndpoint = {
};
},
getLyrics: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
@@ -453,7 +455,7 @@ export const JellyfinController: ControllerEndpoint = {
};
},
getPlaylistDetail: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
@@ -477,7 +479,7 @@ export const JellyfinController: ControllerEndpoint = {
return jfNormalize.playlist(res.body, apiClientProps.server);
},
getPlaylistList: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
@@ -515,7 +517,7 @@ export const JellyfinController: ControllerEndpoint = {
query: { ...query, limit: 1, startIndex: 0 },
}).then((result) => result!.totalRecordCount!),
getPlaylistSongList: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
@@ -547,7 +549,7 @@ export const JellyfinController: ControllerEndpoint = {
};
},
getRandomSongList: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
@@ -668,7 +670,7 @@ export const JellyfinController: ControllerEndpoint = {
}, []);
},
getSongDetail: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const res = await jfApiClient(apiClientProps).getSongDetail({
params: {
@@ -684,7 +686,7 @@ export const JellyfinController: ControllerEndpoint = {
return jfNormalize.song(res.body, apiClientProps.server, '');
},
getSongList: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
@@ -864,7 +866,7 @@ export const JellyfinController: ControllerEndpoint = {
};
},
getTranscodingUrl: (args) => {
const { base, format, bitrate } = args.query;
const { base, bitrate, format } = args.query;
let url = base.replace('transcodingProtocol=hls', 'transcodingProtocol=http');
if (format) {
url = url.replace('audioCodec=aac', `audioCodec=${format}`);
@@ -892,7 +894,7 @@ export const JellyfinController: ControllerEndpoint = {
}
},
removeFromPlaylist: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const chunks = chunk(query.songId, MAX_ITEMS_PER_PLAYLIST_ADD);
@@ -914,7 +916,7 @@ export const JellyfinController: ControllerEndpoint = {
return null;
},
scrobble: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const position = query.position && Math.round(query.position);
@@ -978,7 +980,7 @@ export const JellyfinController: ControllerEndpoint = {
return null;
},
search: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
@@ -1071,7 +1073,7 @@ export const JellyfinController: ControllerEndpoint = {
};
},
updatePlaylist: async (args) => {
const { query, body, apiClientProps } = args;
const { apiClientProps, body, query } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');

View File

@@ -1,18 +1,19 @@
import { nanoid } from 'nanoid';
import { z } from 'zod';
import { JFAlbum, JFPlaylist, JFMusicFolder, JFGenre } from '/@/renderer/api/jellyfin.types';
import { JFAlbum, JFGenre, JFMusicFolder, JFPlaylist } from '/@/renderer/api/jellyfin.types';
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
import {
Song,
LibraryItem,
Album,
AlbumArtist,
Playlist,
MusicFolder,
Genre,
LibraryItem,
MusicFolder,
Playlist,
RelatedArtist,
ServerListItem,
ServerType,
RelatedArtist,
Song,
} from '/@/renderer/api/types';
const getStreamUrl = (args: {
@@ -21,9 +22,9 @@ const getStreamUrl = (args: {
eTag?: string;
id: string;
mediaSourceId?: string;
server: ServerListItem | null;
server: null | ServerListItem;
}) => {
const { id, server, deviceId } = args;
const { deviceId, id, server } = args;
return (
`${server?.url}/audio` +
@@ -122,9 +123,9 @@ const getPlaylistCoverArtUrl = (args: { baseUrl: string; item: JFPlaylist; size:
);
};
type AlbumOrSong = z.infer<typeof jfType._response.song> | z.infer<typeof jfType._response.album>;
type AlbumOrSong = z.infer<typeof jfType._response.album> | z.infer<typeof jfType._response.song>;
const getPeople = (item: AlbumOrSong): Record<string, RelatedArtist[]> | null => {
const getPeople = (item: AlbumOrSong): null | Record<string, RelatedArtist[]> => {
if (item.People) {
const participants: Record<string, RelatedArtist[]> = {};
@@ -151,7 +152,7 @@ const getPeople = (item: AlbumOrSong): Record<string, RelatedArtist[]> | null =>
return null;
};
const getTags = (item: AlbumOrSong): Record<string, string[]> | null => {
const getTags = (item: AlbumOrSong): null | Record<string, string[]> => {
if (item.Tags) {
const tags: Record<string, string[]> = {};
for (const tag of item.Tags) {
@@ -166,7 +167,7 @@ const getTags = (item: AlbumOrSong): Record<string, string[]> | null => {
const normalizeSong = (
item: z.infer<typeof jfType._response.song>,
server: ServerListItem | null,
server: null | ServerListItem,
deviceId: string,
imageSize?: number,
): Song => {
@@ -252,7 +253,7 @@ const normalizeSong = (
const normalizeAlbum = (
item: z.infer<typeof jfType._response.album>,
server: ServerListItem | null,
server: null | ServerListItem,
imageSize?: number,
): Album => {
return {
@@ -312,7 +313,7 @@ const normalizeAlbumArtist = (
item: z.infer<typeof jfType._response.albumArtist> & {
similarArtists?: z.infer<typeof jfType._response.albumArtistList>;
},
server: ServerListItem | null,
server: null | ServerListItem,
imageSize?: number,
): AlbumArtist => {
const similarArtists =
@@ -361,7 +362,7 @@ const normalizeAlbumArtist = (
const normalizePlaylist = (
item: z.infer<typeof jfType._response.playlist>,
server: ServerListItem | null,
server: null | ServerListItem,
imageSize?: number,
): Playlist => {
const imageUrl = getPlaylistCoverArtUrl({
@@ -445,7 +446,7 @@ const getGenreCoverArtUrl = (args: {
);
};
const normalizeGenre = (item: JFGenre, server: ServerListItem | null): Genre => {
const normalizeGenre = (item: JFGenre, server: null | ServerListItem): Genre => {
return {
albumCount: undefined,
id: item.Id,

View File

@@ -95,8 +95,8 @@ const imageBlurHashes = z.object({
const userData = z.object({
IsFavorite: z.boolean(),
Key: z.string(),
PlayCount: z.number(),
PlaybackPositionTicks: z.number(),
PlayCount: z.number(),
Played: z.boolean(),
});
@@ -187,13 +187,13 @@ const sessionInfo = z.object({
LastPlaybackCheckIn: z.string(),
NowPlayingQueue: z.array(z.any()),
NowPlayingQueueFullItems: z.array(z.any()),
PlayableMediaTypes: z.array(z.any()),
PlayState: z.object({
CanSeek: z.boolean(),
IsMuted: z.boolean(),
IsPaused: z.boolean(),
RepeatMode: z.string(),
}),
PlayableMediaTypes: z.array(z.any()),
RemoteEndPoint: z.string(),
ServerId: z.string(),
SupportedCommands: z.array(z.any()),
@@ -223,10 +223,10 @@ const configuration = z.object({
const policy = z.object({
AccessSchedules: z.array(z.any()),
AuthenticationProviderId: z.string(),
BlockUnratedItems: z.array(z.any()),
BlockedChannels: z.array(z.any()),
BlockedMediaFolders: z.array(z.any()),
BlockedTags: z.array(z.any()),
BlockUnratedItems: z.array(z.any()),
EnableAllChannels: z.boolean(),
EnableAllDevices: z.boolean(),
EnableAllFolders: z.boolean(),
@@ -234,6 +234,9 @@ const policy = z.object({
EnableContentDeletion: z.boolean(),
EnableContentDeletionFromFolders: z.array(z.any()),
EnableContentDownloading: z.boolean(),
EnabledChannels: z.array(z.any()),
EnabledDevices: z.array(z.any()),
EnabledFolders: z.array(z.any()),
EnableLiveTvAccess: z.boolean(),
EnableLiveTvManagement: z.boolean(),
EnableMediaConversion: z.boolean(),
@@ -246,9 +249,6 @@ const policy = z.object({
EnableSyncTranscoding: z.boolean(),
EnableUserPreferenceAccess: z.boolean(),
EnableVideoPlaybackTranscoding: z.boolean(),
EnabledChannels: z.array(z.any()),
EnabledDevices: z.array(z.any()),
EnabledFolders: z.array(z.any()),
ForceRemoteSourceTranscoding: z.boolean(),
InvalidLoginAttemptCount: z.number(),
IsAdministrator: z.boolean(),
@@ -414,8 +414,8 @@ const song = z.object({
ImageTags: imageTags,
IndexNumber: z.number(),
IsFolder: z.boolean(),
LUFS: z.number().optional(),
LocationType: z.string(),
LUFS: z.number().optional(),
MediaSources: z.array(mediaSources),
MediaType: z.string(),
Name: z.string(),
@@ -654,8 +654,8 @@ const favorite = z.object({
Key: z.string(),
LastPlayedDate: z.string(),
Likes: z.boolean(),
PlayCount: z.number(),
PlaybackPositionTicks: z.number(),
PlayCount: z.number(),
Played: z.boolean(),
PlayedPercentage: z.number(),
Rating: z.number(),

View File

@@ -1,30 +1,77 @@
import { SSArtistInfo } from '/@/renderer/api/subsonic.types';
export type NDAuthenticate = {
id: string;
isAdmin: boolean;
name: string;
subsonicSalt: string;
subsonicToken: string;
token: string;
username: string;
export enum NDAlbumArtistListSort {
ALBUM_COUNT = 'albumCount',
FAVORITED = 'starred_at',
NAME = 'name',
PLAY_COUNT = 'playCount',
RATING = 'rating',
SONG_COUNT = 'songCount',
}
export enum NDAlbumListSort {
ALBUM_ARTIST = 'album_artist',
ARTIST = 'artist',
DURATION = 'duration',
NAME = 'name',
PLAY_COUNT = 'play_count',
PLAY_DATE = 'play_date',
RANDOM = 'random',
RATING = 'rating',
RECENTLY_ADDED = 'recently_added',
SONG_COUNT = 'songCount',
STARRED = 'starred_at',
YEAR = 'max_year',
}
export enum NDGenreListSort {
NAME = 'name',
}
export enum NDPlaylistListSort {
DURATION = 'duration',
NAME = 'name',
OWNER = 'owner_name',
PUBLIC = 'public',
SONG_COUNT = 'songCount',
UPDATED_AT = 'updatedAt',
}
export enum NDSongListSort {
ALBUM = 'album',
ALBUM_ARTIST = 'order_album_artist_name',
ALBUM_SONGS = 'album',
ARTIST = 'artist',
BPM = 'bpm',
CHANNELS = 'channels',
COMMENT = 'comment',
DURATION = 'duration',
FAVORITED = 'starred_at',
GENRE = 'genre',
ID = 'id',
PLAY_COUNT = 'playCount',
PLAY_DATE = 'playDate',
RANDOM = 'random',
RATING = 'rating',
RECENTLY_ADDED = 'createdAt',
TITLE = 'title',
TRACK = 'track',
YEAR = 'year',
}
export enum NDSortOrder {
ASC = 'ASC',
DESC = 'DESC',
}
export type NDAddToPlaylist = null;
export type NDAddToPlaylistBody = {
ids: string[];
};
export type NDUser = {
createdAt: string;
email: string;
id: string;
isAdmin: boolean;
lastAccessAt: string;
lastLoginAt: string;
name: string;
updatedAt: string;
userName: string;
};
export type NDGenre = {
id: string;
name: string;
export type NDAddToPlaylistResponse = {
added: number;
};
export type NDAlbum = {
@@ -61,6 +108,193 @@ export type NDAlbum = {
updatedAt: string;
} & { songs?: NDSong[] };
export type NDAlbumArtist = {
albumCount: number;
biography: string;
externalInfoUpdatedAt: string;
externalUrl: string;
fullText: string;
genres: NDGenre[];
id: string;
largeImageUrl?: string;
mbzArtistId: string;
mediumImageUrl?: string;
name: string;
orderArtistName: string;
playCount: number;
playDate: string;
rating: number;
size: number;
smallImageUrl?: string;
songCount: number;
starred: boolean;
starredAt: string;
} & {
similarArtists?: SSArtistInfo['similarArtist'];
};
export type NDAlbumArtistDetail = NDAlbumArtist;
export type NDAlbumArtistDetailResponse = NDAlbumArtist;
export type NDAlbumArtistList = {
items: NDAlbumArtist[];
startIndex: number;
totalRecordCount: number;
};
export type NDAlbumArtistListParams = NDOrder &
NDPagination & {
_sort?: NDAlbumArtistListSort;
genre_id?: string;
starred?: boolean;
};
export type NDAlbumDetail = NDAlbum & { songs?: NDSongListResponse };
export type NDAlbumDetailResponse = NDAlbum;
export type NDAlbumList = {
items: NDAlbum[];
startIndex: number;
totalRecordCount: number;
};
export type NDAlbumListParams = NDOrder &
NDPagination & {
_sort?: NDAlbumListSort;
album_id?: string;
artist_id?: string;
compilation?: boolean;
genre_id?: string;
has_rating?: boolean;
id?: string;
name?: string;
recently_played?: boolean;
starred?: boolean;
year?: number;
};
export type NDAlbumListResponse = NDAlbum[];
export type NDArtistListResponse = NDAlbumArtist[];
export type NDAuthenticate = {
id: string;
isAdmin: boolean;
name: string;
subsonicSalt: string;
subsonicToken: string;
token: string;
username: string;
};
export type NDAuthenticationResponse = NDAuthenticate;
export type NDCreatePlaylist = NDCreatePlaylistResponse;
export type NDCreatePlaylistParams = {
comment?: string;
name: string;
public?: boolean;
rules?: null | Record<string, any>;
};
export type NDCreatePlaylistResponse = {
id: string;
};
export type NDDeletePlaylist = NDDeletePlaylistResponse;
export type NDDeletePlaylistParams = {
id: string;
};
export type NDDeletePlaylistResponse = null;
export type NDGenre = {
id: string;
name: string;
};
export type NDGenreList = NDGenre[];
export type NDGenreListParams = NDOrder &
NDPagination & {
_sort?: NDGenreListSort;
id?: string;
};
export type NDGenreListResponse = NDGenre[];
export type NDOrder = {
_order?: NDSortOrder;
};
export type NDPagination = {
_end?: number;
_start?: number;
};
export type NDPlaylist = {
comment: string;
createdAt: string;
duration: number;
evaluatedAt: string;
id: string;
name: string;
ownerId: string;
ownerName: string;
path: string;
public: boolean;
rules: null | Record<string, any>;
size: number;
songCount: number;
sync: boolean;
updatedAt: string;
};
export type NDPlaylistDetail = NDPlaylist;
export type NDPlaylistDetailResponse = NDPlaylist;
export type NDPlaylistList = {
items: NDPlaylist[];
startIndex: number;
totalRecordCount: number;
};
export type NDPlaylistListParams = NDOrder &
NDPagination & {
_sort?: NDPlaylistListSort;
owner_id?: string;
};
export type NDPlaylistListResponse = NDPlaylist[];
export type NDPlaylistSong = NDSong & {
mediaFileId: string;
playlistId: string;
};
export type NDPlaylistSongList = {
items: NDPlaylistSong[];
startIndex: number;
totalRecordCount: number;
};
export type NDPlaylistSongListResponse = NDPlaylistSong[];
export type NDRemoveFromPlaylist = null;
export type NDRemoveFromPlaylistParams = {
id: string[];
};
export type NDRemoveFromPlaylistResponse = {
ids: string[];
};
export type NDSong = {
album: string;
albumArtist: string;
@@ -107,275 +341,41 @@ export type NDSong = {
year: number;
};
export type NDAlbumArtist = {
albumCount: number;
biography: string;
externalInfoUpdatedAt: string;
externalUrl: string;
fullText: string;
genres: NDGenre[];
id: string;
largeImageUrl?: string;
mbzArtistId: string;
mediumImageUrl?: string;
name: string;
orderArtistName: string;
playCount: number;
playDate: string;
rating: number;
size: number;
smallImageUrl?: string;
songCount: number;
starred: boolean;
starredAt: string;
} & {
similarArtists?: SSArtistInfo['similarArtist'];
};
export type NDAuthenticationResponse = NDAuthenticate;
export type NDAlbumArtistList = {
items: NDAlbumArtist[];
startIndex: number;
totalRecordCount: number;
};
export type NDAlbumArtistDetail = NDAlbumArtist;
export type NDAlbumArtistDetailResponse = NDAlbumArtist;
export type NDGenreList = NDGenre[];
export type NDGenreListResponse = NDGenre[];
export type NDAlbumDetailResponse = NDAlbum;
export type NDAlbumDetail = NDAlbum & { songs?: NDSongListResponse };
export type NDAlbumListResponse = NDAlbum[];
export type NDAlbumList = {
items: NDAlbum[];
startIndex: number;
totalRecordCount: number;
};
export type NDSongDetail = NDSong;
export type NDSongDetailResponse = NDSong;
export type NDSongListResponse = NDSong[];
export type NDSongList = {
items: NDSong[];
startIndex: number;
totalRecordCount: number;
};
export type NDArtistListResponse = NDAlbumArtist[];
export type NDSongListParams = NDOrder &
NDPagination & {
_sort?: NDSongListSort;
album_id?: string[];
artist_id?: string[];
genre_id?: string;
starred?: boolean;
};
export type NDPagination = {
_end?: number;
_start?: number;
};
export enum NDSortOrder {
ASC = 'ASC',
DESC = 'DESC',
}
export type NDOrder = {
_order?: NDSortOrder;
};
export enum NDGenreListSort {
NAME = 'name',
}
export type NDGenreListParams = {
_sort?: NDGenreListSort;
id?: string;
} & NDPagination &
NDOrder;
export enum NDAlbumListSort {
ALBUM_ARTIST = 'album_artist',
ARTIST = 'artist',
DURATION = 'duration',
NAME = 'name',
PLAY_COUNT = 'play_count',
PLAY_DATE = 'play_date',
RANDOM = 'random',
RATING = 'rating',
RECENTLY_ADDED = 'recently_added',
SONG_COUNT = 'songCount',
STARRED = 'starred_at',
YEAR = 'max_year',
}
export type NDAlbumListParams = {
_sort?: NDAlbumListSort;
album_id?: string;
artist_id?: string;
compilation?: boolean;
genre_id?: string;
has_rating?: boolean;
id?: string;
name?: string;
recently_played?: boolean;
starred?: boolean;
year?: number;
} & NDPagination &
NDOrder;
export enum NDSongListSort {
ALBUM = 'album',
ALBUM_ARTIST = 'order_album_artist_name',
ALBUM_SONGS = 'album',
ARTIST = 'artist',
BPM = 'bpm',
CHANNELS = 'channels',
COMMENT = 'comment',
DURATION = 'duration',
FAVORITED = 'starred_at',
GENRE = 'genre',
ID = 'id',
PLAY_COUNT = 'playCount',
PLAY_DATE = 'playDate',
RANDOM = 'random',
RATING = 'rating',
RECENTLY_ADDED = 'createdAt',
TITLE = 'title',
TRACK = 'track',
YEAR = 'year',
}
export type NDSongListParams = {
_sort?: NDSongListSort;
album_id?: string[];
artist_id?: string[];
genre_id?: string;
starred?: boolean;
} & NDPagination &
NDOrder;
export enum NDAlbumArtistListSort {
ALBUM_COUNT = 'albumCount',
FAVORITED = 'starred_at',
NAME = 'name',
PLAY_COUNT = 'playCount',
RATING = 'rating',
SONG_COUNT = 'songCount',
}
export type NDAlbumArtistListParams = {
_sort?: NDAlbumArtistListSort;
genre_id?: string;
starred?: boolean;
} & NDPagination &
NDOrder;
export type NDAddToPlaylistResponse = {
added: number;
};
export type NDAddToPlaylistBody = {
ids: string[];
};
export type NDAddToPlaylist = null;
export type NDRemoveFromPlaylistResponse = {
ids: string[];
};
export type NDRemoveFromPlaylistParams = {
id: string[];
};
export type NDRemoveFromPlaylist = null;
export type NDCreatePlaylistParams = {
comment?: string;
name: string;
public?: boolean;
rules?: Record<string, any> | null;
};
export type NDCreatePlaylistResponse = {
id: string;
};
export type NDCreatePlaylist = NDCreatePlaylistResponse;
export type NDSongListResponse = NDSong[];
export type NDUpdatePlaylistParams = Partial<NDPlaylist>;
export type NDUpdatePlaylistResponse = NDPlaylist;
export type NDDeletePlaylistParams = {
id: string;
};
export type NDDeletePlaylistResponse = null;
export type NDDeletePlaylist = NDDeletePlaylistResponse;
export type NDPlaylist = {
comment: string;
export type NDUser = {
createdAt: string;
duration: number;
evaluatedAt: string;
email: string;
id: string;
isAdmin: boolean;
lastAccessAt: string;
lastLoginAt: string;
name: string;
ownerId: string;
ownerName: string;
path: string;
public: boolean;
rules: Record<string, any> | null;
size: number;
songCount: number;
sync: boolean;
updatedAt: string;
};
export type NDPlaylistDetail = NDPlaylist;
export type NDPlaylistDetailResponse = NDPlaylist;
export type NDPlaylistList = {
items: NDPlaylist[];
startIndex: number;
totalRecordCount: number;
};
export type NDPlaylistListResponse = NDPlaylist[];
export enum NDPlaylistListSort {
DURATION = 'duration',
NAME = 'name',
OWNER = 'owner_name',
PUBLIC = 'public',
SONG_COUNT = 'songCount',
UPDATED_AT = 'updatedAt',
}
export type NDPlaylistListParams = {
_sort?: NDPlaylistListSort;
owner_id?: string;
} & NDPagination &
NDOrder;
export type NDPlaylistSong = NDSong & {
mediaFileId: string;
playlistId: string;
};
export type NDPlaylistSongListResponse = NDPlaylistSong[];
export type NDPlaylistSongList = {
items: NDPlaylistSong[];
startIndex: number;
totalRecordCount: number;
userName: string;
};
export const NDSongQueryFields = [
@@ -515,12 +515,9 @@ export const NDSongQueryNumberOperators = [
{ label: 'is in the range', value: 'inTheRange' },
];
export type NDUserListParams = {
_sort?: NDUserListSort;
} & NDPagination &
NDOrder;
export type NDUserListResponse = NDUser[];
export enum NDUserListSort {
NAME = 'name',
}
export type NDUserList = {
items: NDUser[];
@@ -528,6 +525,9 @@ export type NDUserList = {
totalRecordCount: number;
};
export enum NDUserListSort {
NAME = 'name',
}
export type NDUserListParams = NDOrder &
NDPagination & {
_sort?: NDUserListSort;
};
export type NDUserListResponse = NDUser[];

View File

@@ -1,17 +1,19 @@
import { initClient, initContract } from '@ts-rest/core';
import axios, { Method, AxiosError, AxiosResponse, isAxiosError } from 'axios';
import axios, { AxiosError, AxiosResponse, isAxiosError, Method } from 'axios';
import isElectron from 'is-electron';
import debounce from 'lodash/debounce';
import omitBy from 'lodash/omitBy';
import qs from 'qs';
import { ndType } from './navidrome-types';
import { authenticationFailure, resultWithHeaders } from '/@/renderer/api/utils';
import { useAuthStore } from '/@/renderer/store';
import { ServerListItem } from '/@/renderer/api/types';
import { toast } from '/@/renderer/components/toast';
import i18n from '/@/i18n/i18n';
const localSettings = isElectron() ? window.electron.localSettings : null;
import { ndType } from './navidrome-types';
import i18n from '/@/i18n/i18n';
import { ServerListItem } from '/@/renderer/api/types';
import { authenticationFailure, resultWithHeaders } from '/@/renderer/api/utils';
import { toast } from '/@/renderer/components/toast';
import { useAuthStore } from '/@/renderer/store';
const localSettings = isElectron() ? window.api.localSettings : null;
const c = initContract();
@@ -275,7 +277,7 @@ axiosClient.interceptors.response.use(
// eslint-disable-next-line promise/no-promise-in-callback
return localSettings
.passwordGet(currentServer.id)
.then(async (password: string | null) => {
.then(async (password: null | string) => {
authSuccess = false;
if (password === null) {
@@ -367,14 +369,14 @@ axiosClient.interceptors.response.use(
);
export const ndApiClient = (args: {
server: ServerListItem | null;
server: null | ServerListItem;
signal?: AbortSignal;
url?: string;
}) => {
const { server, url, signal } = args;
const { server, signal, url } = args;
return initClient(contract, {
api: async ({ path, method, headers, body }) => {
api: async ({ body, headers, method, path }) => {
let baseUrl: string | undefined;
let token: string | undefined;
@@ -406,7 +408,7 @@ export const ndApiClient = (args: {
headers: result.headers as any,
status: result.status,
};
} catch (e: Error | AxiosError | any) {
} catch (e: any | AxiosError | Error) {
if (isAxiosError(e)) {
if (e.code === 'ERR_NETWORK') {
throw new Error(

View File

@@ -1,28 +1,29 @@
import {
albumArtistListSortMap,
albumListSortMap,
AuthenticationResponse,
ControllerEndpoint,
genreListSortMap,
playlistListSortMap,
PlaylistSongListArgs,
PlaylistSongListResponse,
ServerListItem,
Song,
songListSortMap,
sortOrderMap,
userListSortMap,
} from '../types';
import { ServerFeature, ServerFeatures } from '/@/renderer/api/features-types';
import { NDSongListSort } from '/@/renderer/api/navidrome.types';
import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
import { ndNormalize } from '/@/renderer/api/navidrome/navidrome-normalize';
import { ndType } from '/@/renderer/api/navidrome/navidrome-types';
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
import {
albumArtistListSortMap,
sortOrderMap,
AuthenticationResponse,
userListSortMap,
albumListSortMap,
songListSortMap,
playlistListSortMap,
PlaylistSongListArgs,
PlaylistSongListResponse,
genreListSortMap,
Song,
ControllerEndpoint,
ServerListItem,
} from '../types';
import { VersionInfo, getFeatures, hasFeature } from '/@/renderer/api/utils';
import { ServerFeature, ServerFeatures } from '/@/renderer/api/features-types';
import { SubsonicExtensions } from '/@/renderer/api/subsonic/subsonic-types';
import { NDSongListSort } from '/@/renderer/api/navidrome.types';
import { ssNormalize } from '/@/renderer/api/subsonic/subsonic-normalize';
import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller';
import { ssNormalize } from '/@/renderer/api/subsonic/subsonic-normalize';
import { SubsonicExtensions } from '/@/renderer/api/subsonic/subsonic-types';
import { getFeatures, hasFeature, VersionInfo } from '/@/renderer/api/utils';
const VERSION_INFO: VersionInfo = [
['0.55.0', { [ServerFeature.BFR]: [1] }],
@@ -48,7 +49,7 @@ const NAVIDROME_ROLES: Array<string | { label: string; value: string }> = [
const EXCLUDED_TAGS = new Set<string>(['disctotal', 'genre', 'tracktotal']);
const excludeMissing = (server: ServerListItem | null) => {
const excludeMissing = (server: null | ServerListItem) => {
if (hasFeature(server, ServerFeature.BFR)) {
return { missing: false };
}
@@ -58,7 +59,7 @@ const excludeMissing = (server: ServerListItem | null) => {
export const NavidromeController: ControllerEndpoint = {
addToPlaylist: async (args) => {
const { body, query, apiClientProps } = args;
const { apiClientProps, body, query } = args;
const res = await ndApiClient(apiClientProps).addToPlaylist({
body: {
@@ -98,7 +99,7 @@ export const NavidromeController: ControllerEndpoint = {
},
createFavorite: SubsonicController.createFavorite,
createPlaylist: async (args) => {
const { body, apiClientProps } = args;
const { apiClientProps, body } = args;
const res = await ndApiClient(apiClientProps).createPlaylist({
body: {
@@ -120,7 +121,7 @@ export const NavidromeController: ControllerEndpoint = {
},
deleteFavorite: SubsonicController.deleteFavorite,
deletePlaylist: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps).deletePlaylist({
params: {
@@ -135,7 +136,7 @@ export const NavidromeController: ControllerEndpoint = {
return null;
},
getAlbumArtistDetail: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps).getAlbumArtistDetail({
params: {
@@ -176,7 +177,7 @@ export const NavidromeController: ControllerEndpoint = {
);
},
getAlbumArtistList: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps).getAlbumArtistList({
query: {
@@ -217,7 +218,7 @@ export const NavidromeController: ControllerEndpoint = {
query: { ...query, limit: 1, startIndex: 0 },
}).then((result) => result!.totalRecordCount!),
getAlbumDetail: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const albumRes = await ndApiClient(apiClientProps).getAlbumDetail({
params: {
@@ -245,7 +246,7 @@ export const NavidromeController: ControllerEndpoint = {
);
},
getAlbumInfo: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const albumInfo = await ssApiClient(apiClientProps).getAlbumInfo2({
query: {
@@ -265,7 +266,7 @@ export const NavidromeController: ControllerEndpoint = {
};
},
getAlbumList: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps).getAlbumList({
query: {
@@ -299,7 +300,7 @@ export const NavidromeController: ControllerEndpoint = {
query: { ...query, limit: 1, startIndex: 0 },
}).then((result) => result!.totalRecordCount!),
getArtistList: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps).getAlbumArtistList({
query: {
@@ -341,7 +342,7 @@ export const NavidromeController: ControllerEndpoint = {
}).then((result) => result!.totalRecordCount!),
getDownloadUrl: SubsonicController.getDownloadUrl,
getGenreList: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps).getGenreList({
query: {
@@ -366,7 +367,7 @@ export const NavidromeController: ControllerEndpoint = {
getLyrics: SubsonicController.getLyrics,
getMusicFolderList: SubsonicController.getMusicFolderList,
getPlaylistDetail: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps).getPlaylistDetail({
params: {
@@ -381,7 +382,7 @@ export const NavidromeController: ControllerEndpoint = {
return ndNormalize.playlist(res.body.data, apiClientProps.server);
},
getPlaylistList: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const customQuery = query._custom?.navidrome;
// Smart playlists only became available in 0.48.0. Do not filter for previous versions
@@ -420,7 +421,7 @@ export const NavidromeController: ControllerEndpoint = {
query: { ...query, limit: 1, startIndex: 0 },
}).then((result) => result!.totalRecordCount!),
getPlaylistSongList: async (args: PlaylistSongListArgs): Promise<PlaylistSongListResponse> => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps).getPlaylistSongList({
params: {
@@ -548,7 +549,7 @@ export const NavidromeController: ControllerEndpoint = {
}, []);
},
getSongDetail: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps).getSongDetail({
params: {
@@ -563,7 +564,7 @@ export const NavidromeController: ControllerEndpoint = {
return ndNormalize.song(res.body.data, apiClientProps.server);
},
getSongList: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps).getSongList({
query: {
@@ -642,7 +643,7 @@ export const NavidromeController: ControllerEndpoint = {
getTopSongs: SubsonicController.getTopSongs,
getTranscodingUrl: SubsonicController.getTranscodingUrl,
getUserList: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps).getUserList({
query: {
@@ -682,7 +683,7 @@ export const NavidromeController: ControllerEndpoint = {
}
},
removeFromPlaylist: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps).removeFromPlaylist({
params: {
@@ -703,7 +704,7 @@ export const NavidromeController: ControllerEndpoint = {
search: SubsonicController.search,
setRating: SubsonicController.setRating,
shareItem: async (args) => {
const { body, apiClientProps } = args;
const { apiClientProps, body } = args;
const res = await ndApiClient(apiClientProps).shareItem({
body: {
@@ -724,7 +725,7 @@ export const NavidromeController: ControllerEndpoint = {
};
},
updatePlaylist: async (args) => {
const { query, body, apiClientProps } = args;
const { apiClientProps, body, query } = args;
const res = await ndApiClient(apiClientProps).updatePlaylist({
body: {

View File

@@ -1,22 +1,24 @@
import { nanoid } from 'nanoid';
import z from 'zod';
import { ndType } from './navidrome-types';
import { NDGenre } from '/@/renderer/api/navidrome.types';
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
import {
Song,
LibraryItem,
Album,
Playlist,
User,
AlbumArtist,
Genre,
LibraryItem,
Playlist,
RelatedArtist,
ServerListItem,
ServerType,
RelatedArtist,
Song,
User,
} from '/@/renderer/api/types';
import z from 'zod';
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 getImageUrl = (args: { url: null | string }) => {
const { url } = args;
if (url === '/app/artist-placeholder.webp') {
return null;
@@ -51,19 +53,19 @@ interface WithDate {
playDate?: string;
}
const normalizePlayDate = (item: WithDate): string | null => {
const normalizePlayDate = (item: WithDate): null | string => {
return !item.playDate || item.playDate.includes('0001-') ? null : item.playDate;
};
const getArtists = (
item:
| z.infer<typeof ndType._response.song>
| z.infer<typeof ndType._response.album>
| z.infer<typeof ndType._response.playlistSong>
| z.infer<typeof ndType._response.album>,
| z.infer<typeof ndType._response.song>,
) => {
let albumArtists: RelatedArtist[] | undefined;
let artists: RelatedArtist[] | undefined;
let participants: Record<string, RelatedArtist[]> | null = null;
let participants: null | Record<string, RelatedArtist[]> = null;
if (item.participants) {
participants = {};
@@ -120,8 +122,8 @@ const getArtists = (
};
const normalizeSong = (
item: z.infer<typeof ndType._response.song> | z.infer<typeof ndType._response.playlistSong>,
server: ServerListItem | null,
item: z.infer<typeof ndType._response.playlistSong> | z.infer<typeof ndType._response.song>,
server: null | ServerListItem,
imageSize?: number,
): Song => {
let id;
@@ -204,7 +206,7 @@ const normalizeAlbum = (
item: z.infer<typeof ndType._response.album> & {
songs?: z.infer<typeof ndType._response.songList>;
},
server: ServerListItem | null,
server: null | ServerListItem,
imageSize?: number,
): Album => {
const imageUrl = getCoverArtUrl({
@@ -268,7 +270,7 @@ const normalizeAlbumArtist = (
item: z.infer<typeof ndType._response.albumArtist> & {
similarArtists?: z.infer<typeof ssType._response.artistInfo>['artistInfo']['similarArtist'];
},
server: ServerListItem | null,
server: null | ServerListItem,
): AlbumArtist => {
let imageUrl = getImageUrl({ url: item?.largeImageUrl || null });
@@ -332,7 +334,7 @@ const normalizeAlbumArtist = (
const normalizePlaylist = (
item: z.infer<typeof ndType._response.playlist>,
server: ServerListItem | null,
server: null | ServerListItem,
imageSize?: number,
): Playlist => {
const imageUrl = getCoverArtUrl({

View File

@@ -1,4 +1,5 @@
import { z } from 'zod';
import {
NDAlbumArtistListSort,
NDAlbumListSort,

View File

@@ -1,28 +1,30 @@
import { QueryFunctionContext } from '@tanstack/react-query';
import { LyricSource } from './types';
import type {
AlbumListQuery,
SongListQuery,
AlbumDetailQuery,
AlbumArtistListQuery,
ArtistListQuery,
PlaylistListQuery,
PlaylistDetailQuery,
PlaylistSongListQuery,
UserListQuery,
AlbumArtistDetailQuery,
TopSongListQuery,
SearchQuery,
SongDetailQuery,
RandomSongListQuery,
LyricsQuery,
LyricSearchQuery,
AlbumArtistListQuery,
AlbumDetailQuery,
AlbumListQuery,
ArtistListQuery,
GenreListQuery,
LyricSearchQuery,
LyricsQuery,
PlaylistDetailQuery,
PlaylistListQuery,
PlaylistSongListQuery,
RandomSongListQuery,
SearchQuery,
SimilarSongsQuery,
SongDetailQuery,
SongListQuery,
TopSongListQuery,
UserListQuery,
} from './types';
import { LyricSource } from './types';
export const splitPaginatedQuery = (key: any) => {
const { startIndex, limit, ...filter } = key || {};
const { limit, startIndex, ...filter } = key || {};
if (startIndex !== undefined || limit !== undefined) {
return {
@@ -51,7 +53,7 @@ export const queryKeys: Record<
> = {
albumArtists: {
count: (serverId: string, query?: AlbumArtistListQuery) => {
const { pagination, filter } = splitPaginatedQuery(query);
const { filter, pagination } = splitPaginatedQuery(query);
if (query && pagination) {
return [serverId, 'albumArtists', 'count', filter, pagination] as const;
@@ -68,7 +70,7 @@ export const queryKeys: Record<
return [serverId, 'albumArtists', 'detail'] as const;
},
list: (serverId: string, query?: AlbumArtistListQuery) => {
const { pagination, filter } = splitPaginatedQuery(query);
const { filter, pagination } = splitPaginatedQuery(query);
if (query && pagination) {
return [serverId, 'albumArtists', 'list', filter, pagination] as const;
}
@@ -87,7 +89,7 @@ export const queryKeys: Record<
},
albums: {
count: (serverId: string, query?: AlbumListQuery, artistId?: string) => {
const { pagination, filter } = splitPaginatedQuery(query);
const { filter, pagination } = splitPaginatedQuery(query);
if (query && pagination && artistId) {
return [serverId, 'albums', 'count', artistId, filter, pagination] as const;
@@ -110,7 +112,7 @@ export const queryKeys: Record<
detail: (serverId: string, query?: AlbumDetailQuery) =>
[serverId, 'albums', 'detail', query] as const,
list: (serverId: string, query?: AlbumListQuery, artistId?: string) => {
const { pagination, filter } = splitPaginatedQuery(query);
const { filter, pagination } = splitPaginatedQuery(query);
if (query && pagination && artistId) {
return [serverId, 'albums', 'list', artistId, filter, pagination] as const;
@@ -144,7 +146,7 @@ export const queryKeys: Record<
},
artists: {
list: (serverId: string, query?: ArtistListQuery) => {
const { pagination, filter } = splitPaginatedQuery(query);
const { filter, pagination } = splitPaginatedQuery(query);
if (query && pagination) {
return [serverId, 'artists', 'list', filter, pagination] as const;
}
@@ -159,7 +161,7 @@ export const queryKeys: Record<
},
genres: {
list: (serverId: string, query?: GenreListQuery) => {
const { pagination, filter } = splitPaginatedQuery(query);
const { filter, pagination } = splitPaginatedQuery(query);
if (query && pagination) {
return [serverId, 'genres', 'list', filter, pagination] as const;
}
@@ -177,7 +179,7 @@ export const queryKeys: Record<
},
playlists: {
detail: (serverId: string, id?: string, query?: PlaylistDetailQuery) => {
const { pagination, filter } = splitPaginatedQuery(query);
const { filter, pagination } = splitPaginatedQuery(query);
if (query && pagination) {
return [serverId, 'playlists', id, 'detail', filter, pagination] as const;
}
@@ -190,7 +192,7 @@ export const queryKeys: Record<
return [serverId, 'playlists', 'detail'] as const;
},
detailSongList: (serverId: string, id: string, query?: PlaylistSongListQuery) => {
const { pagination, filter } = splitPaginatedQuery(query);
const { filter, pagination } = splitPaginatedQuery(query);
if (query && id && pagination) {
return [serverId, 'playlists', id, 'detailSongList', filter, pagination] as const;
@@ -205,7 +207,7 @@ export const queryKeys: Record<
return [serverId, 'playlists', 'detailSongList'] as const;
},
list: (serverId: string, query?: PlaylistListQuery) => {
const { pagination, filter } = splitPaginatedQuery(query);
const { filter, pagination } = splitPaginatedQuery(query);
if (query && pagination) {
return [serverId, 'playlists', 'list', filter, pagination] as const;
}
@@ -218,7 +220,7 @@ export const queryKeys: Record<
},
root: (serverId: string) => [serverId, 'playlists'] as const,
songList: (serverId: string, id?: string, query?: PlaylistSongListQuery) => {
const { pagination, filter } = splitPaginatedQuery(query);
const { filter, pagination } = splitPaginatedQuery(query);
if (query && id && pagination) {
return [serverId, 'playlists', id, 'songList', filter, pagination] as const;
}
@@ -246,7 +248,7 @@ export const queryKeys: Record<
},
songs: {
count: (serverId: string, query?: SongListQuery) => {
const { pagination, filter } = splitPaginatedQuery(query);
const { filter, pagination } = splitPaginatedQuery(query);
if (query && pagination) {
return [serverId, 'songs', 'count', filter, pagination] as const;
}
@@ -262,7 +264,7 @@ export const queryKeys: Record<
return [serverId, 'songs', 'detail'] as const;
},
list: (serverId: string, query?: SongListQuery) => {
const { pagination, filter } = splitPaginatedQuery(query);
const { filter, pagination } = splitPaginatedQuery(query);
if (query && pagination) {
return [serverId, 'songs', 'list', filter, pagination] as const;
}

View File

@@ -1,32 +1,13 @@
export type SSBaseResponse = {
serverVersion?: 'string';
status: 'string';
type?: 'string';
version: 'string';
export type SSAlbum = SSAlbumListEntry & {
song: SSSong[];
};
export type SSMusicFolderList = SSMusicFolder[];
export type SSMusicFolderListResponse = {
musicFolders: {
musicFolder: SSMusicFolder[];
};
};
export type SSGenreList = SSGenre[];
export type SSGenreListResponse = {
genres: {
genre: SSGenre[];
};
};
export type SSAlbumArtistDetail = SSAlbumArtistListEntry & { album: SSAlbumListEntry[] };
export type SSAlbumArtistDetailParams = {
id: string;
};
export type SSAlbumArtistDetail = SSAlbumArtistListEntry & { album: SSAlbumListEntry[] };
export type SSAlbumArtistDetailResponse = {
artist: SSAlbumArtistListEntry & {
album: SSAlbumListEntry[];
@@ -36,7 +17,19 @@ export type SSAlbumArtistDetailResponse = {
export type SSAlbumArtistList = {
items: SSAlbumArtistListEntry[];
startIndex: number;
totalRecordCount: number | null;
totalRecordCount: null | number;
};
export type SSAlbumArtistListEntry = {
albumCount: string;
artistImageUrl?: string;
coverArt?: string;
id: string;
name: string;
};
export type SSAlbumArtistListParams = {
musicFolderId?: string;
};
export type SSAlbumArtistListResponse = {
@@ -47,72 +40,16 @@ export type SSAlbumArtistListResponse = {
};
};
export type SSAlbumList = {
items: SSAlbumListEntry[];
startIndex: number;
totalRecordCount: number | null;
};
export type SSAlbumListResponse = {
albumList2: {
album: SSAlbumListEntry[];
};
};
export type SSAlbumDetail = Omit<SSAlbum, 'song'> & { songs: SSSong[] };
export type SSAlbumDetailResponse = {
album: SSAlbum;
};
export type SSArtistInfoParams = {
count?: number;
id: string;
includeNotPresent?: boolean;
};
export type SSArtistInfoResponse = {
artistInfo2: SSArtistInfo;
};
export type SSArtistInfo = {
biography: string;
largeImageUrl?: string;
lastFmUrl?: string;
mediumImageUrl?: string;
musicBrainzId?: string;
similarArtist?: {
albumCount: string;
artistImageUrl?: string;
coverArt?: string;
id: string;
name: string;
}[];
smallImageUrl?: string;
};
export type SSMusicFolder = {
id: number;
name: string;
};
export type SSGenre = {
albumCount?: number;
songCount?: number;
value: string;
};
export type SSArtistIndex = {
artist: SSAlbumArtistListEntry[];
name: string;
};
export type SSAlbumArtistListEntry = {
albumCount: string;
artistImageUrl?: string;
coverArt?: string;
id: string;
name: string;
export type SSAlbumList = {
items: SSAlbumListEntry[];
startIndex: number;
totalRecordCount: null | number;
};
export type SSAlbumListEntry = {
@@ -135,9 +72,111 @@ export type SSAlbumListEntry = {
year: number;
};
export type SSAlbum = {
song: SSSong[];
} & SSAlbumListEntry;
export type SSAlbumListParams = {
fromYear?: number;
genre?: string;
musicFolderId?: string;
offset?: number;
size?: number;
toYear?: number;
type: string;
};
export type SSAlbumListResponse = {
albumList2: {
album: SSAlbumListEntry[];
};
};
export type SSArtistIndex = {
artist: SSAlbumArtistListEntry[];
name: string;
};
export type SSArtistInfo = {
biography: string;
largeImageUrl?: string;
lastFmUrl?: string;
mediumImageUrl?: string;
musicBrainzId?: string;
similarArtist?: {
albumCount: string;
artistImageUrl?: string;
coverArt?: string;
id: string;
name: string;
}[];
smallImageUrl?: string;
};
export type SSArtistInfoParams = {
count?: number;
id: string;
includeNotPresent?: boolean;
};
export type SSArtistInfoResponse = {
artistInfo2: SSArtistInfo;
};
export type SSBaseResponse = {
serverVersion?: 'string';
status: 'string';
type?: 'string';
version: 'string';
};
export type SSFavorite = null;
export type SSFavoriteParams = {
albumId?: string;
artistId?: string;
id?: string;
};
export type SSFavoriteResponse = null;
export type SSGenre = {
albumCount?: number;
songCount?: number;
value: string;
};
export type SSGenreList = SSGenre[];
export type SSGenreListResponse = {
genres: {
genre: SSGenre[];
};
};
export type SSMusicFolder = {
id: number;
name: string;
};
export type SSMusicFolderList = SSMusicFolder[];
export type SSMusicFolderListResponse = {
musicFolders: {
musicFolder: SSMusicFolder[];
};
};
export type SSRating = null;
export type SSRatingParams = {
id: string;
rating: number;
};
export type SSRatingResponse = null;
export type SSScrobbleParams = {
id: string;
submission?: boolean;
time?: number;
};
export type SSSong = {
album: string;
@@ -167,39 +206,12 @@ export type SSSong = {
year: number;
};
export type SSAlbumListParams = {
fromYear?: number;
genre?: string;
musicFolderId?: string;
offset?: number;
size?: number;
toYear?: number;
type: string;
export type SSTopSongList = {
items: SSSong[];
startIndex: number;
totalRecordCount: null | number;
};
export type SSAlbumArtistListParams = {
musicFolderId?: string;
};
export type SSFavoriteParams = {
albumId?: string;
artistId?: string;
id?: string;
};
export type SSFavorite = null;
export type SSFavoriteResponse = null;
export type SSRatingParams = {
id: string;
rating: number;
};
export type SSRating = null;
export type SSRatingResponse = null;
export type SSTopSongListParams = {
artist: string;
count?: number;
@@ -210,15 +222,3 @@ export type SSTopSongListResponse = {
song: SSSong[];
};
};
export type SSTopSongList = {
items: SSSong[];
startIndex: number;
totalRecordCount: number | null;
};
export type SSScrobbleParams = {
id: string;
submission?: boolean;
time?: number;
};

View File

@@ -1,12 +1,13 @@
import { initClient, initContract } from '@ts-rest/core';
import axios, { Method, AxiosError, isAxiosError, AxiosResponse } from 'axios';
import axios, { AxiosError, AxiosResponse, isAxiosError, Method } from 'axios';
import omitBy from 'lodash/omitBy';
import qs from 'qs';
import { z } from 'zod';
import i18n from '/@/i18n/i18n';
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
import { ServerListItem } from '/@/renderer/api/types';
import { toast } from '/@/renderer/components/toast/index';
import i18n from '/@/i18n/i18n';
const c = initContract();
@@ -284,15 +285,15 @@ const silentlyTransformResponse = (data: any) => {
};
export const ssApiClient = (args: {
server: ServerListItem | null;
server: null | ServerListItem;
signal?: AbortSignal;
silent?: boolean;
url?: string;
}) => {
const { server, url, signal, silent } = args;
const { server, signal, silent, url } = args;
return initClient(contract, {
api: async ({ path, method, headers, body }) => {
api: async ({ body, headers, method, path }) => {
let baseUrl: string | undefined;
const authParams: Record<string, any> = {};
@@ -339,7 +340,7 @@ export const ssApiClient = (args: {
headers: result.headers as any,
status: result.status,
};
} catch (e: Error | AxiosError | any) {
} catch (e: any | AxiosError | Error) {
if (isAxiosError(e)) {
if (e.code === 'ERR_NETWORK') {
throw new Error(

View File

@@ -2,44 +2,45 @@ import dayjs from 'dayjs';
import filter from 'lodash/filter';
import orderBy from 'lodash/orderBy';
import md5 from 'md5';
import { ServerFeatures } from '/@/renderer/api/features-types';
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
import { ssNormalize } from '/@/renderer/api/subsonic/subsonic-normalize';
import { AlbumListSortType, SubsonicExtensions } from '/@/renderer/api/subsonic/subsonic-types';
import {
LibraryItem,
Song,
ControllerEndpoint,
sortSongList,
sortAlbumArtistList,
PlaylistListSort,
GenreListSort,
AlbumListSort,
ControllerEndpoint,
GenreListSort,
LibraryItem,
PlaylistListSort,
Song,
sortAlbumArtistList,
sortAlbumList,
SortOrder,
sortSongList,
} from '/@/renderer/api/types';
import { randomString } from '/@/renderer/utils';
import { ServerFeatures } from '/@/renderer/api/features-types';
const ALBUM_LIST_SORT_MAPPING: Record<AlbumListSort, AlbumListSortType | undefined> = {
[AlbumListSort.RANDOM]: AlbumListSortType.RANDOM,
[AlbumListSort.ALBUM_ARTIST]: AlbumListSortType.ALPHABETICAL_BY_ARTIST,
[AlbumListSort.PLAY_COUNT]: AlbumListSortType.FREQUENT,
[AlbumListSort.RECENTLY_ADDED]: AlbumListSortType.NEWEST,
[AlbumListSort.FAVORITED]: AlbumListSortType.STARRED,
[AlbumListSort.YEAR]: AlbumListSortType.BY_YEAR,
[AlbumListSort.NAME]: AlbumListSortType.ALPHABETICAL_BY_NAME,
[AlbumListSort.COMMUNITY_RATING]: undefined,
[AlbumListSort.DURATION]: undefined,
[AlbumListSort.CRITIC_RATING]: undefined,
[AlbumListSort.RATING]: undefined,
[AlbumListSort.ARTIST]: undefined,
[AlbumListSort.COMMUNITY_RATING]: undefined,
[AlbumListSort.CRITIC_RATING]: undefined,
[AlbumListSort.DURATION]: undefined,
[AlbumListSort.FAVORITED]: AlbumListSortType.STARRED,
[AlbumListSort.NAME]: AlbumListSortType.ALPHABETICAL_BY_NAME,
[AlbumListSort.PLAY_COUNT]: AlbumListSortType.FREQUENT,
[AlbumListSort.RANDOM]: AlbumListSortType.RANDOM,
[AlbumListSort.RATING]: undefined,
[AlbumListSort.RECENTLY_ADDED]: AlbumListSortType.NEWEST,
[AlbumListSort.RECENTLY_PLAYED]: AlbumListSortType.RECENT,
[AlbumListSort.RELEASE_DATE]: undefined,
[AlbumListSort.SONG_COUNT]: undefined,
[AlbumListSort.YEAR]: AlbumListSortType.BY_YEAR,
};
export const SubsonicController: ControllerEndpoint = {
addToPlaylist: async ({ body, query, apiClientProps }) => {
addToPlaylist: async ({ apiClientProps, body, query }) => {
const res = await ssApiClient(apiClientProps).updatePlaylist({
query: {
playlistId: query.id,
@@ -98,7 +99,7 @@ export const SubsonicController: ControllerEndpoint = {
};
},
createFavorite: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const res = await ssApiClient(apiClientProps).createFavorite({
query: {
@@ -114,7 +115,7 @@ export const SubsonicController: ControllerEndpoint = {
return null;
},
createPlaylist: async ({ body, apiClientProps }) => {
createPlaylist: async ({ apiClientProps, body }) => {
const res = await ssApiClient(apiClientProps).createPlaylist({
query: {
name: body.name,
@@ -131,7 +132,7 @@ export const SubsonicController: ControllerEndpoint = {
};
},
deleteFavorite: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const res = await ssApiClient(apiClientProps).removeFavorite({
query: {
@@ -148,7 +149,7 @@ export const SubsonicController: ControllerEndpoint = {
return null;
},
deletePlaylist: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const res = await ssApiClient(apiClientProps).deletePlaylist({
query: {
@@ -163,7 +164,7 @@ export const SubsonicController: ControllerEndpoint = {
return null;
},
getAlbumArtistDetail: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const artistInfoRes = await ssApiClient(apiClientProps).getArtistInfo({
query: {
@@ -198,7 +199,7 @@ export const SubsonicController: ControllerEndpoint = {
};
},
getAlbumArtistList: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const res = await ssApiClient(apiClientProps).getArtists({
query: {
@@ -237,7 +238,7 @@ export const SubsonicController: ControllerEndpoint = {
getAlbumArtistListCount: (args) =>
SubsonicController.getAlbumArtistList(args).then((res) => res!.totalRecordCount!),
getAlbumDetail: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const res = await ssApiClient(apiClientProps).getAlbum({
query: {
@@ -252,7 +253,7 @@ export const SubsonicController: ControllerEndpoint = {
return ssNormalize.album(res.body.album, apiClientProps.server);
},
getAlbumList: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
if (query.searchTerm) {
const res = await ssApiClient(apiClientProps).search3({
@@ -398,7 +399,7 @@ export const SubsonicController: ControllerEndpoint = {
};
},
getAlbumListCount: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
if (query.searchTerm) {
let fetchNextPage = true;
@@ -516,7 +517,7 @@ export const SubsonicController: ControllerEndpoint = {
return totalRecordCount;
},
getArtistList: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const res = await ssApiClient(apiClientProps).getArtists({
query: {
@@ -570,7 +571,7 @@ export const SubsonicController: ControllerEndpoint = {
'&c=Feishin'
);
},
getGenreList: async ({ query, apiClientProps }) => {
getGenreList: async ({ apiClientProps, query }) => {
const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc';
const res = await ssApiClient(apiClientProps).getGenres({});
@@ -624,7 +625,7 @@ export const SubsonicController: ControllerEndpoint = {
};
},
getPlaylistDetail: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const res = await ssApiClient(apiClientProps).getPlaylist({
query: {
@@ -638,7 +639,7 @@ export const SubsonicController: ControllerEndpoint = {
return ssNormalize.playlist(res.body.playlist, apiClientProps.server);
},
getPlaylistList: async ({ query, apiClientProps }) => {
getPlaylistList: async ({ apiClientProps, query }) => {
const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc';
const res = await ssApiClient(apiClientProps).getPlaylists({});
@@ -686,7 +687,7 @@ export const SubsonicController: ControllerEndpoint = {
totalRecordCount: results.length,
};
},
getPlaylistListCount: async ({ query, apiClientProps }) => {
getPlaylistListCount: async ({ apiClientProps, query }) => {
const res = await ssApiClient(apiClientProps).getPlaylists({});
if (res.status !== 200) {
@@ -705,7 +706,7 @@ export const SubsonicController: ControllerEndpoint = {
return results.length;
},
getPlaylistSongList: async ({ query, apiClientProps }) => {
getPlaylistSongList: async ({ apiClientProps, query }) => {
const res = await ssApiClient(apiClientProps).getPlaylist({
query: {
id: query.id,
@@ -731,7 +732,7 @@ export const SubsonicController: ControllerEndpoint = {
};
},
getRandomSongList: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const res = await ssApiClient(apiClientProps).getRandomSongList({
query: {
@@ -842,7 +843,7 @@ export const SubsonicController: ControllerEndpoint = {
}, []);
},
getSongDetail: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const res = await ssApiClient(apiClientProps).getSong({
query: {
@@ -856,7 +857,7 @@ export const SubsonicController: ControllerEndpoint = {
return ssNormalize.song(res.body.song, apiClientProps.server);
},
getSongList: async ({ query, apiClientProps }) => {
getSongList: async ({ apiClientProps, query }) => {
const fromAlbumPromises = [];
const artistDetailPromises = [];
let results: any[] = [];
@@ -1028,7 +1029,7 @@ export const SubsonicController: ControllerEndpoint = {
};
},
getSongListCount: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
let fetchNextPage = true;
let startIndex = 0;
@@ -1196,7 +1197,7 @@ export const SubsonicController: ControllerEndpoint = {
return totalRecordCount;
},
getStructuredLyrics: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const res = await ssApiClient(apiClientProps).getStructuredLyrics({
query: {
@@ -1238,7 +1239,7 @@ export const SubsonicController: ControllerEndpoint = {
});
},
getTopSongs: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const res = await ssApiClient(apiClientProps).getTopSongsList({
query: {
@@ -1261,7 +1262,7 @@ export const SubsonicController: ControllerEndpoint = {
};
},
getTranscodingUrl: (args) => {
const { base, format, bitrate } = args.query;
const { base, bitrate, format } = args.query;
let url = base;
if (format) {
url += `&format=${format}`;
@@ -1272,7 +1273,7 @@ export const SubsonicController: ControllerEndpoint = {
return url;
},
removeFromPlaylist: async ({ query, apiClientProps }) => {
removeFromPlaylist: async ({ apiClientProps, query }) => {
const res = await ssApiClient(apiClientProps).updatePlaylist({
query: {
playlistId: query.id,
@@ -1287,7 +1288,7 @@ export const SubsonicController: ControllerEndpoint = {
return null;
},
scrobble: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const res = await ssApiClient(apiClientProps).scrobble({
query: {
@@ -1304,7 +1305,7 @@ export const SubsonicController: ControllerEndpoint = {
},
search: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const res = await ssApiClient(apiClientProps).search3({
query: {
@@ -1335,7 +1336,7 @@ export const SubsonicController: ControllerEndpoint = {
};
},
setRating: async (args) => {
const { query, apiClientProps } = args;
const { apiClientProps, query } = args;
const itemIds = query.item.map((item) => item.id);
@@ -1351,7 +1352,7 @@ export const SubsonicController: ControllerEndpoint = {
return null;
},
updatePlaylist: async (args) => {
const { body, query, apiClientProps } = args;
const { apiClientProps, body, query } = args;
const res = await ssApiClient(apiClientProps).updatePlaylist({
query: {

View File

@@ -1,16 +1,17 @@
import { nanoid } from 'nanoid';
import { z } from 'zod';
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
import {
QueueSong,
LibraryItem,
AlbumArtist,
Album,
AlbumArtist,
Genre,
LibraryItem,
Playlist,
QueueSong,
RelatedArtist,
ServerListItem,
ServerType,
Playlist,
Genre,
RelatedArtist,
} from '/@/renderer/api/types';
const getCoverArtUrl = (args: {
@@ -37,9 +38,9 @@ const getCoverArtUrl = (args: {
const getArtists = (
item:
| z.infer<typeof ssType._response.song>
| z.infer<typeof ssType._response.album>
| z.infer<typeof ssType._response.albumListEntry>,
| z.infer<typeof ssType._response.albumListEntry>
| z.infer<typeof ssType._response.song>,
) => {
const albumArtists: RelatedArtist[] = item.albumArtists
? item.albumArtists.map((item) => ({
@@ -69,7 +70,7 @@ const getArtists = (
},
];
let participants: Record<string, RelatedArtist[]> | null = null;
let participants: null | Record<string, RelatedArtist[]> = null;
if (item.contributors) {
participants = {};
@@ -98,9 +99,9 @@ const getArtists = (
const getGenres = (
item:
| z.infer<typeof ssType._response.song>
| z.infer<typeof ssType._response.album>
| z.infer<typeof ssType._response.albumListEntry>,
| z.infer<typeof ssType._response.albumListEntry>
| z.infer<typeof ssType._response.song>,
): Genre[] => {
return item.genres
? item.genres.map((genre) => ({
@@ -123,7 +124,7 @@ const getGenres = (
const normalizeSong = (
item: z.infer<typeof ssType._response.song>,
server: ServerListItem | null,
server: null | ServerListItem,
size?: number,
): QueueSong => {
const imageUrl =
@@ -194,7 +195,7 @@ const normalizeAlbumArtist = (
item:
| z.infer<typeof ssType._response.albumArtist>
| z.infer<typeof ssType._response.artistListEntry>,
server: ServerListItem | null,
server: null | ServerListItem,
imageSize?: number,
): AlbumArtist => {
const imageUrl =
@@ -229,7 +230,7 @@ const normalizeAlbumArtist = (
const normalizeAlbum = (
item: z.infer<typeof ssType._response.album> | z.infer<typeof ssType._response.albumListEntry>,
server: ServerListItem | null,
server: null | ServerListItem,
imageSize?: number,
): Album => {
const imageUrl =
@@ -280,7 +281,7 @@ const normalizePlaylist = (
item:
| z.infer<typeof ssType._response.playlist>
| z.infer<typeof ssType._response.playlistListEntry>,
server: ServerListItem | null,
server: null | ServerListItem,
): Playlist => {
return {
description: item.comment || null,

View File

File diff suppressed because it is too large Load Diff

View File

@@ -3,10 +3,11 @@ import isElectron from 'is-electron';
import semverCoerce from 'semver/functions/coerce';
import semverGte from 'semver/functions/gte';
import { z } from 'zod';
import { ServerFeature } from '/@/renderer/api/features-types';
import { ServerListItem } from '/@/renderer/api/types';
import { toast } from '/@/renderer/components/toast';
import { useAuthStore } from '/@/renderer/store';
import { ServerListItem } from '/@/renderer/api/types';
import { ServerFeature } from '/@/renderer/api/features-types';
// 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) => {
@@ -29,7 +30,7 @@ export const resultSubsonicBaseResponse = <ItemType extends z.ZodRawShape>(
});
};
export const authenticationFailure = (currentServer: ServerListItem | null) => {
export const authenticationFailure = (currentServer: null | ServerListItem) => {
toast.error({
message: 'Your session has expired.',
});
@@ -43,7 +44,7 @@ export const authenticationFailure = (currentServer: ServerListItem | null) => {
}
};
export const hasFeature = (server: ServerListItem | null, feature: ServerFeature): boolean => {
export const hasFeature = (server: null | ServerListItem, feature: ServerFeature): boolean => {
if (!server || !server.features) {
return false;
}

View File

@@ -1,33 +1,39 @@
import { useEffect, useMemo, useState, useRef } from 'react';
import { ClientSideRowModelModule } from '@ag-grid-community/client-side-row-model';
import { ModuleRegistry } from '@ag-grid-community/core';
import { InfiniteRowModelModule } from '@ag-grid-community/infinite-row-model';
import { MantineProvider } from '@mantine/core';
import isElectron from 'is-electron';
import { useEffect, useMemo, useRef, useState } from 'react';
import { initSimpleImg } from 'react-simple-img';
import i18n from '../i18n/i18n';
import { toast } from './components';
import { useTheme } from './hooks';
import { IsUpdatedDialog } from './is-updated-dialog';
import { AppRouter } from './router/app-router';
import './styles/global.scss';
import '@ag-grid-community/styles/ag-grid.css';
import 'overlayscrollbars/overlayscrollbars.css';
import {
useCssSettings,
useHotkeySettings,
usePlaybackSettings,
useRemoteSettings,
useSettingsStore,
} from './store/settings.store';
import './styles/global.scss';
import { ContextMenuProvider } from '/@/renderer/features/context-menu';
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
import { PlayQueueHandlerContext } from '/@/renderer/features/player';
import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings';
import { PlayerState, useCssSettings, usePlayerStore, useQueueControls } from '/@/renderer/store';
import { FontType, PlaybackType, PlayerStatus, WebAudio } from '/@/renderer/types';
import '@ag-grid-community/styles/ag-grid.css';
import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context';
import { useDiscordRpc } from '/@/renderer/features/discord-rpc/use-discord-rpc';
import i18n from '/@/i18n/i18n';
import { useServerVersion } from '/@/renderer/hooks/use-server-version';
import { PlayQueueHandlerContext } from '/@/renderer/features/player';
import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context';
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
import { updateSong } from '/@/renderer/features/player/update-remote-song';
import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings';
import { useServerVersion } from '/@/renderer/hooks/use-server-version';
import { IsUpdatedDialog } from '/@/renderer/is-updated-dialog';
import { PlayerState, usePlayerStore, useQueueControls } from '/@/renderer/store';
import { FontType, PlaybackType, PlayerStatus, WebAudio } from '/@/renderer/types';
import { sanitizeCss } from '/@/renderer/utils/sanitize';
import { setQueue } from '/@/renderer/utils/set-transcoded-queue-data';
@@ -35,10 +41,10 @@ ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule
initSimpleImg({ threshold: 0.05 }, true);
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
const ipc = isElectron() ? window.electron.ipc : null;
const remote = isElectron() ? window.electron.remote : null;
const utils = isElectron() ? window.electron.utils : null;
const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
const ipc = isElectron() ? window.api.ipc : null;
const remote = isElectron() ? window.api.remote : null;
const utils = isElectron() ? window.api.utils : null;
export const App = () => {
const theme = useTheme();
@@ -46,7 +52,7 @@ export const App = () => {
const language = useSettingsStore((store) => store.general.language);
const nativeImageAspect = useSettingsStore((store) => store.general.nativeAspectRatio);
const { builtIn, custom, system, type } = useSettingsStore((state) => state.font);
const { enabled, content } = useCssSettings();
const { content, enabled } = useCssSettings();
const { type: playbackType } = usePlaybackSettings();
const { bindings } = useHotkeySettings();
const handlePlayQueueAdd = useHandlePlayQueueAdd();
@@ -230,10 +236,8 @@ export const App = () => {
return (
<MantineProvider
withGlobalStyles
withNormalizeCSS
theme={{
colorScheme: theme as 'light' | 'dark',
colorScheme: theme as 'dark' | 'light',
components: {
Modal: {
styles: {
@@ -282,6 +286,8 @@ export const App = () => {
xs: '0rem',
},
}}
withGlobalStyles
withNormalizeCSS
>
<PlayQueueHandlerContext.Provider value={providerValue}>
<ContextMenuProvider>

31
src/renderer/assets/assets.d.ts vendored Normal file
View File

@@ -0,0 +1,31 @@
type Styles = Record<string, string>;
declare module '*.svg' {
const content: string;
export default content;
}
declare module '*.png' {
const content: string;
export default content;
}
declare module '*.jpg' {
const content: string;
export default content;
}
declare module '*.scss' {
const content: Styles;
export default content;
}
declare module '*.sass' {
const content: Styles;
export default content;
}
declare module '*.css' {
const content: Styles;
export default content;
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
</dict>
</plist>

Some files were not shown because too many files have changed in this diff Show More