Merge branch 'development' into navidrome-version

This commit is contained in:
Jeff 2024-03-04 01:49:13 -08:00 committed by GitHub
commit cc6cad1d70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 531 additions and 345 deletions

3
.dockerignore Normal file
View file

@ -0,0 +1,3 @@
node_modules
Dockerfile
docker-compose.*

View file

@ -16,7 +16,7 @@ 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 // 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 // at the dev webpack config is not accidentally run in a production environment
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
checkNodeEnv('development'); checkNodeEnv('development');
} }
const port = process.env.PORT || 4343; const port = process.env.PORT || 4343;
@ -28,171 +28,174 @@ const requiredByDLLConfig = module.parent!.filename.includes('webpack.config.ren
* Warn if the DLL is not built * Warn if the DLL is not built
*/ */
if (!requiredByDLLConfig && !(fs.existsSync(webpackPaths.dllPath) && fs.existsSync(manifest))) { if (!requiredByDLLConfig && !(fs.existsSync(webpackPaths.dllPath) && fs.existsSync(manifest))) {
console.log( console.log(
chalk.black.bgYellow.bold( chalk.black.bgYellow.bold(
'The DLL files are missing. Sit back while we build them for you with "npm run build-dll"', 'The DLL files are missing. Sit back while we build them for you with "npm run build-dll"',
), ),
); );
execSync('npm run postinstall'); execSync('npm run postinstall');
} }
const configuration: webpack.Configuration = { const configuration: webpack.Configuration = {
devtool: 'inline-source-map', devtool: 'inline-source-map',
mode: 'development', mode: 'development',
target: ['web', 'electron-renderer'], target: ['web', 'electron-renderer'],
entry: [ entry: [
`webpack-dev-server/client?http://localhost:${port}/dist`, `webpack-dev-server/client?http://localhost:${port}/dist`,
'webpack/hot/only-dev-server', 'webpack/hot/only-dev-server',
path.join(webpackPaths.srcRendererPath, 'index.tsx'), 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(), output: {
path: webpackPaths.distRendererPath,
/** publicPath: '/',
* Create global constants which can be configured at compile time. filename: 'renderer.dev.js',
* library: {
* Useful for allowing different behaviour between development builds and type: 'umd',
* 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,
}),
],
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...'); module: {
const remoteProcess = spawn('npm', ['run', 'start:remote'], { rules: [
shell: true, {
stdio: 'inherit', test: /\.s?css$/,
}) use: [
.on('close', (code: number) => process.exit(code!)) 'style-loader',
.on('error', (spawnError) => console.error(spawnError)); {
loader: 'css-loader',
console.log('Starting Main Process...'); options: {
spawn('npm', ['run', 'start:main'], { modules: {
shell: true, localIdentName: '[name]__[local]--[hash:base64:5]',
stdio: 'inherit', exportLocalsConvention: 'camelCaseOnly',
}) },
.on('close', (code: number) => { sourceMap: true,
preloadProcess.kill(); importLoaders: 1,
remoteProcess.kill(); },
process.exit(code!); },
}) 'sass-loader',
.on('error', (spawnError) => console.error(spawnError)); ],
return middlewares; 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); export default merge(baseConfig, configuration);

View file

@ -21,114 +21,117 @@ checkNodeEnv('production');
deleteSourceMaps(); deleteSourceMaps();
const devtoolsConfig = const devtoolsConfig =
process.env.DEBUG_PROD === 'true' process.env.DEBUG_PROD === 'true'
? { ? {
devtool: 'source-map', devtool: 'source-map',
} }
: {}; : {};
const configuration: webpack.Configuration = { const configuration: webpack.Configuration = {
...devtoolsConfig, ...devtoolsConfig,
mode: 'production', mode: 'production',
target: ['web', 'electron-renderer'], target: ['web', 'electron-renderer'],
entry: [path.join(webpackPaths.srcRendererPath, 'index.tsx')], entry: [path.join(webpackPaths.srcRendererPath, 'index.tsx')],
output: { output: {
path: webpackPaths.distRendererPath, path: webpackPaths.distRendererPath,
publicPath: './', publicPath: './',
filename: 'renderer.js', filename: 'renderer.js',
library: { library: {
type: 'umd', type: 'umd',
},
}, },
},
module: { module: {
rules: [ rules: [
{ {
test: /\.s?(a|c)ss$/, test: /\.s?(a|c)ss$/,
use: [ use: [
MiniCssExtractPlugin.loader, MiniCssExtractPlugin.loader,
{ {
loader: 'css-loader', loader: 'css-loader',
options: { options: {
modules: { modules: {
localIdentName: '[name]__[local]--[hash:base64:5]', localIdentName: '[name]__[local]--[hash:base64:5]',
exportLocalsConvention: 'camelCaseOnly', exportLocalsConvention: 'camelCaseOnly',
}, },
sourceMap: true, sourceMap: true,
importLoaders: 1, 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',
}, },
},
'sass-loader',
], ],
include: /\.module\.s?(c|a)ss$/, },
},
{ optimization: {
test: /\.s?(a|c)ss$/, minimize: true,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'], minimizer: [
exclude: /\.module\.s?(c|a)ss$/, new TerserPlugin({
}, parallel: true,
// Fonts }),
{ new CssMinimizerPlugin(),
test: /\.(woff|woff2|eot|ttf|otf)$/i, ],
type: 'asset/resource', },
},
// Images plugins: [
{ /**
test: /\.(png|svg|jpg|jpeg|gif)$/i, * Create global constants which can be configured at compile time.
type: 'asset/resource', *
}, * 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,
},
}),
], ],
},
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',
}),
],
}; };
export default merge(baseConfig, configuration); export default merge(baseConfig, configuration);

View file

@ -116,6 +116,9 @@ const configuration: webpack.Configuration = {
env: process.env.NODE_ENV, env: process.env.NODE_ENV,
isDevelopment: process.env.NODE_ENV !== 'production', isDevelopment: process.env.NODE_ENV !== 'production',
nodeModules: webpackPaths.appNodeModulesPath, nodeModules: webpackPaths.appNodeModulesPath,
templateParameters: {
web: false, // with hot reload, we don't have NGINX injecting variables
},
}), }),
], ],

View file

@ -128,6 +128,9 @@ const configuration: webpack.Configuration = {
}, },
isBrowser: false, isBrowser: false,
isDevelopment: process.env.NODE_ENV !== 'production', isDevelopment: process.env.NODE_ENV !== 'production',
templateParameters: {
web: true,
},
}), }),
], ],
}; };

View file

@ -1,16 +1,20 @@
# --- Builder stage # --- Builder stage
FROM node:18-alpine as builder FROM node:18-alpine as builder
WORKDIR /app WORKDIR /app
COPY . /app
#Copy package.json first to cache node_modules
COPY package.json package-lock.json .
# Scripts include electron-specific dependencies, which we don't need # Scripts include electron-specific dependencies, which we don't need
RUN npm install --legacy-peer-deps --ignore-scripts RUN npm install --legacy-peer-deps --ignore-scripts
#Copy code and build with cached modules
COPY . .
RUN npm run build:web RUN npm run build:web
# --- Production stage # --- Production stage
FROM nginx:alpine-slim FROM nginx:alpine-slim
COPY --chown=nginx:nginx --from=builder /app/release/app/dist/web /usr/share/nginx/html COPY --chown=nginx:nginx --from=builder /app/release/app/dist/web /usr/share/nginx/html
COPY ./settings.js.template /etc/nginx/templates/settings.js.template
COPY ng.conf.template /etc/nginx/templates/default.conf.template COPY ng.conf.template /etc/nginx/templates/default.conf.template
ENV PUBLIC_PATH="/" ENV PUBLIC_PATH="/"

View file

@ -81,6 +81,8 @@ docker run --name feishin -p 9180:9180 feishin
3. _Optional_ - If you want to host Feishin on a subpath (not `/`), then pass in the following environment variable: `PUBLIC_PATH=PATH`. For example, to host on `/feishin`, pass in `PUBLIC_PATH=/feishin`. 3. _Optional_ - If you want to host Feishin on a subpath (not `/`), then pass in the following environment variable: `PUBLIC_PATH=PATH`. For example, to host on `/feishin`, pass in `PUBLIC_PATH=/feishin`.
4. _Optional_ - To hard code the server url, pass the following environment variables: `SERVER_NAME`, `SERVER_TYPE` (one of `jellyfin` or `navidrome`), `SERVER_URL`. To prevent users from changing these settings, pass `SERVER_LOCK=true`. This can only be set if all three of the previous values are set.
## FAQ ## FAQ
### MPV is either not working or is rapidly switching between pause/play states ### MPV is either not working or is rapidly switching between pause/play states

13
docker-compose.yaml Normal file
View file

@ -0,0 +1,13 @@
version: '3.5'
services:
feishin:
container_name: feishin
image: jeffvli/feishin
restart: unless-stopped
ports:
- 9180:9180
environment:
- SERVER_NAME=jellyfin # pre defined server name
- SERVER_LOCK=true # When true AND name/type/url are set, only username/password can be toggled
- SERVER_TYPE=jellyfin # navidrome also works
- SERVER_URL= # http://address:port

View file

@ -16,4 +16,12 @@ server {
alias /usr/share/nginx/html/; alias /usr/share/nginx/html/;
try_files $uri $uri/ /index.html =404; 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;
}
} }

12
package-lock.json generated
View file

@ -12532,9 +12532,9 @@
} }
}, },
"node_modules/ip": { "node_modules/ip": {
"version": "1.1.5", "version": "1.1.9",
"resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz", "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.9.tgz",
"integrity": "sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=", "integrity": "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==",
"dev": true "dev": true
}, },
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
@ -30639,9 +30639,9 @@
} }
}, },
"ip": { "ip": {
"version": "1.1.5", "version": "1.1.9",
"resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz", "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.9.tgz",
"integrity": "sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=", "integrity": "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==",
"dev": true "dev": true
}, },
"ipaddr.js": { "ipaddr.js": {

View file

@ -9,7 +9,7 @@
"build:remote": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.remote.prod.ts", "build:remote": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.remote.prod.ts",
"build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.prod.ts", "build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.prod.ts",
"build:web": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.web.prod.ts", "build:web": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.web.prod.ts",
"build:docker": "npm run build:web && docker build -t jeffvli/feishin .", "build:docker": "docker build -t jeffvli/feishin .",
"rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app", "rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app",
"lint": "concurrently \"npm run lint:code\" \"npm run lint:styles\"", "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:code": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx --fix",

1
settings.js.template Normal file
View file

@ -0,0 +1 @@
"use strict";window.SERVER_URL="${SERVER_URL}";window.SERVER_NAME="${SERVER_NAME}";window.SERVER_TYPE="${SERVER_TYPE}";window.SERVER_LOCK=${SERVER_LOCK};

View file

@ -38,6 +38,7 @@
"channel_other": "channels", "channel_other": "channels",
"clear": "clear", "clear": "clear",
"close": "close", "close": "close",
"codec": "codec",
"collapse": "collapse", "collapse": "collapse",
"comingSoon": "coming soon…", "comingSoon": "coming soon…",
"configure": "configure", "configure": "configure",
@ -567,6 +568,8 @@
"skipDuration_description": "sets the duration to skip when using the skip buttons on the player bar", "skipDuration_description": "sets the duration to skip when using the skip buttons on the player bar",
"skipPlaylistPage": "skip playlist page", "skipPlaylistPage": "skip playlist page",
"skipPlaylistPage_description": "when navigating to a playlist, go to the playlist song list page instead of the default page", "skipPlaylistPage_description": "when navigating to a playlist, go to the playlist song list page instead of the default page",
"startMinimized": "start minimized",
"startMinimized_description": "start the application in system tray",
"theme": "theme", "theme": "theme",
"theme_description": "sets the theme to use for the application", "theme_description": "sets the theme to use for the application",
"themeDark": "theme (dark)", "themeDark": "theme (dark)",
@ -592,6 +595,7 @@
"bitrate": "bitrate", "bitrate": "bitrate",
"bpm": "bpm", "bpm": "bpm",
"channels": "$t(common.channel_other)", "channels": "$t(common.channel_other)",
"codec": "$t(common.codec)",
"comment": "comment", "comment": "comment",
"dateAdded": "date added", "dateAdded": "date added",
"discNumber": "disc", "discNumber": "disc",
@ -626,6 +630,7 @@
"bitrate": "$t(common.bitrate)", "bitrate": "$t(common.bitrate)",
"bpm": "$t(common.bpm)", "bpm": "$t(common.bpm)",
"channels": "$t(common.channel_other)", "channels": "$t(common.channel_other)",
"codec": "$t(common.codec)",
"dateAdded": "date added", "dateAdded": "date added",
"discNumber": "disc number", "discNumber": "disc number",
"duration": "$t(common.duration)", "duration": "$t(common.duration)",

View file

@ -323,13 +323,16 @@ ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean)
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append'); await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
} }
} }
if (pause) {
await getMpvInstance()?.pause();
} else if (pause === false) {
// Only force play if pause is explicitly false
await getMpvInstance()?.play();
}
} catch (err: NodeMpvError | any) { } catch (err: NodeMpvError | any) {
mpvLog({ action: `Failed to set play queue` }, err); mpvLog({ action: `Failed to set play queue` }, err);
} }
if (pause) {
getMpvInstance()?.pause();
}
}); });
// Replaces the queue in position 1 to the given data // Replaces the queue in position 1 to the given data

View file

@ -206,7 +206,7 @@ const createTray = () => {
tray.setContextMenu(contextMenu); tray.setContextMenu(contextMenu);
}; };
const createWindow = async () => { const createWindow = async (first = true) => {
if (isDevelopment) { if (isDevelopment) {
await installExtensions(); await installExtensions();
} }
@ -350,19 +350,21 @@ const createWindow = async () => {
mainWindow.loadURL(resolveHtmlPath('index.html')); mainWindow.loadURL(resolveHtmlPath('index.html'));
const startWindowMinimized = store.get('window_start_minimized', false) as boolean;
mainWindow.on('ready-to-show', () => { mainWindow.on('ready-to-show', () => {
if (!mainWindow) { if (!mainWindow) {
throw new Error('"mainWindow" is not defined'); throw new Error('"mainWindow" is not defined');
} }
if (process.env.START_MINIMIZED) {
mainWindow.minimize(); if (!first || !startWindowMinimized) {
} else {
mainWindow.show(); mainWindow.show();
createWinThumbarButtons(); createWinThumbarButtons();
} }
}); });
mainWindow.on('closed', () => { mainWindow.on('closed', () => {
ipcMain.removeHandler('window-clear-cache');
mainWindow = null; mainWindow = null;
}); });
@ -552,6 +554,7 @@ app.on('window-all-closed', () => {
// Respect the OSX convention of having the application in memory even // Respect the OSX convention of having the application in memory even
// after all windows have been closed // after all windows have been closed
if (isMacOS()) { if (isMacOS()) {
ipcMain.removeHandler('window-clear-cache');
mainWindow = null; mainWindow = null;
} else { } else {
app.quit(); app.quit();
@ -606,7 +609,11 @@ if (!singleInstance) {
app.on('activate', () => { app.on('activate', () => {
// On macOS it's common to re-create a window in the app when the // On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open. // dock icon is clicked and there are no other windows open.
if (mainWindow === null) createWindow(); if (mainWindow === null) createWindow(false);
else if (!mainWindow.isVisible()) {
mainWindow.show();
createWinThumbarButtons();
}
}); });
}) })
.catch(console.log); .catch(console.log);

View file

@ -1,6 +1,6 @@
import { IpcRendererEvent, ipcRenderer, webFrame } from 'electron'; import { IpcRendererEvent, ipcRenderer, webFrame } from 'electron';
import Store from 'electron-store'; import Store from 'electron-store';
import type { TitleTheme } from '/@/renderer/types'; import { toServerType, type TitleTheme } from '/@/renderer/types';
const store = new Store(); const store = new Store();
@ -56,9 +56,20 @@ const themeSet = (theme: TitleTheme): void => {
ipcRenderer.send('theme-set', theme); ipcRenderer.send('theme-set', theme);
}; };
const SERVER_TYPE = toServerType(process.env.SERVER_TYPE);
const env = {
SERVER_LOCK:
SERVER_TYPE !== null ? process.env.SERVER_LOCK?.toLocaleLowerCase() === 'true' : false,
SERVER_NAME: process.env.SERVER_NAME ?? '',
SERVER_TYPE,
SERVER_URL: process.env.SERVER_URL ?? 'http://',
};
export const localSettings = { export const localSettings = {
disableMediaKeys, disableMediaKeys,
enableMediaKeys, enableMediaKeys,
env,
fontError, fontError,
get, get,
passwordGet, passwordGet,

View file

@ -4,6 +4,7 @@ import {
ReactNode, ReactNode,
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
@ -114,9 +115,11 @@ export const SwiperGridCarousel = ({
isLoading, isLoading,
uniqueId, uniqueId,
}: SwiperGridCarouselProps) => { }: SwiperGridCarouselProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const swiperRef = useRef<SwiperCore | any>(null); const swiperRef = useRef<SwiperCore | any>(null);
const playButtonBehavior = usePlayButtonBehavior(); const playButtonBehavior = usePlayButtonBehavior();
const handlePlayQueueAdd = usePlayQueueAdd(); const handlePlayQueueAdd = usePlayQueueAdd();
const [slideCount, setSlideCount] = useState(4);
useEffect(() => { useEffect(() => {
swiperRef.current?.slideTo(0, 0); swiperRef.current?.slideTo(0, 0);
@ -191,23 +194,24 @@ export const SwiperGridCarousel = ({
const handleNext = useCallback(() => { const handleNext = useCallback(() => {
const activeIndex = swiperRef?.current?.activeIndex || 0; const activeIndex = swiperRef?.current?.activeIndex || 0;
const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || 4)); const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || slideCount));
swiperRef?.current?.slideTo(activeIndex + slidesPerView); swiperRef?.current?.slideTo(activeIndex + slidesPerView);
}, [swiperProps?.slidesPerView]); }, [slideCount, swiperProps?.slidesPerView]);
const handlePrev = useCallback(() => { const handlePrev = useCallback(() => {
const activeIndex = swiperRef?.current?.activeIndex || 0; const activeIndex = swiperRef?.current?.activeIndex || 0;
const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || 4)); const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || slideCount));
swiperRef?.current?.slideTo(activeIndex - slidesPerView); swiperRef?.current?.slideTo(activeIndex - slidesPerView);
}, [swiperProps?.slidesPerView]); }, [slideCount, swiperProps?.slidesPerView]);
const handleOnSlideChange = useCallback((e: SwiperCore) => { const handleOnSlideChange = useCallback((e: SwiperCore) => {
const { slides, isEnd, isBeginning, params } = e; const { slides, isEnd, isBeginning, params } = e;
if (isEnd || isBeginning) return; if (isEnd || isBeginning) return;
const slideCount = (params.slidesPerView as number | undefined) || 4;
setPagination({ setPagination({
hasNextPage: (params?.slidesPerView || 4) < slides.length, hasNextPage: slideCount < slides.length,
hasPreviousPage: (params?.slidesPerView || 4) < slides.length, hasPreviousPage: slideCount < slides.length,
}); });
}, []); }, []);
@ -215,82 +219,106 @@ export const SwiperGridCarousel = ({
const { slides, isEnd, isBeginning, params } = e; const { slides, isEnd, isBeginning, params } = e;
if (isEnd || isBeginning) return; if (isEnd || isBeginning) return;
const slideCount = (params.slidesPerView as number | undefined) || 4;
setPagination({ setPagination({
hasNextPage: (params.slidesPerView || 4) < slides.length, hasNextPage: slideCount < slides.length,
hasPreviousPage: (params.slidesPerView || 4) < slides.length, hasPreviousPage: slideCount < slides.length,
}); });
}, []); }, []);
const handleOnReachEnd = useCallback((e: SwiperCore) => { const handleOnReachEnd = useCallback((e: SwiperCore) => {
const { slides, params } = e; const { slides, params } = e;
const slideCount = (params.slidesPerView as number | undefined) || 4;
setPagination({ setPagination({
hasNextPage: false, hasNextPage: false,
hasPreviousPage: (params.slidesPerView || 4) < slides.length, hasPreviousPage: slideCount < slides.length,
}); });
}, []); }, []);
const handleOnReachBeginning = useCallback((e: SwiperCore) => { const handleOnReachBeginning = useCallback((e: SwiperCore) => {
const { slides, params } = e; const { slides, params } = e;
const slideCount = (params.slidesPerView as number | undefined) || 4;
setPagination({ setPagination({
hasNextPage: (params.slidesPerView || 4) < slides.length, hasNextPage: slideCount < slides.length,
hasPreviousPage: false, hasPreviousPage: false,
}); });
}, []); }, []);
const handleOnResize = useCallback((e: SwiperCore) => { useLayoutEffect(() => {
if (!e) return; const handleResize = () => {
const { width } = e; // Use the container div ref and not swiper width, as this value is more accurate
const slidesPerView = getSlidesPerView(width); const width = containerRef.current?.clientWidth;
if (!e.params) return; const { activeIndex, params, slides } =
e.params.slidesPerView = slidesPerView; (swiperRef.current as SwiperCore | undefined) ?? {};
}, []);
const throttledOnResize = throttle(handleOnResize, 200); if (width) {
const slidesPerView = getSlidesPerView(width);
setSlideCount(slidesPerView);
}
if (activeIndex !== undefined && slides && params?.slidesPerView) {
const slideCount = (params.slidesPerView as number | undefined) || 4;
setPagination({
hasNextPage: activeIndex + slideCount < slides.length,
hasPreviousPage: activeIndex > 0,
});
}
};
handleResize();
const throttledResize = throttle(handleResize, 200);
window.addEventListener('resize', throttledResize);
return () => {
window.removeEventListener('resize', throttledResize);
};
}, []);
return ( return (
<CarouselContainer <CarouselContainer
className="grid-carousel" className="grid-carousel"
spacing="md" spacing="md"
> >
{title ? ( <div ref={containerRef}>
<Title {title ? (
{...title} <Title
handleNext={handleNext} {...title}
handlePrev={handlePrev} handleNext={handleNext}
pagination={pagination} handlePrev={handlePrev}
/> pagination={pagination}
) : null} />
<Swiper ) : null}
ref={swiperRef} <Swiper
resizeObserver ref={swiperRef}
modules={[Virtual]} resizeObserver
slidesPerView={4} modules={[Virtual]}
spaceBetween={20} slidesPerView={slideCount}
style={{ height: '100%', width: '100%' }} spaceBetween={20}
onBeforeInit={(swiper) => { style={{ height: '100%', width: '100%' }}
swiperRef.current = swiper; onBeforeInit={(swiper) => {
}} swiperRef.current = swiper;
onBeforeResize={handleOnResize} }}
onReachBeginning={handleOnReachBeginning} onReachBeginning={handleOnReachBeginning}
onReachEnd={handleOnReachEnd} onReachEnd={handleOnReachEnd}
onResize={throttledOnResize} onSlideChange={handleOnSlideChange}
onSlideChange={handleOnSlideChange} onZoomChange={handleOnZoomChange}
onZoomChange={handleOnZoomChange} {...swiperProps}
{...swiperProps} >
> {slides.map((slideContent, index) => {
{slides.map((slideContent, index) => { return (
return ( <SwiperSlide
<SwiperSlide key={`${uniqueId}-${slideContent?.props?.data?.id}-${index}`}
key={`${uniqueId}-${slideContent?.props?.data?.id}-${index}`} virtualIndex={index}
virtualIndex={index} >
> {slideContent}
{slideContent} </SwiperSlide>
</SwiperSlide> );
); })}
})} </Swiper>
</Swiper> </div>
</CarouselContainer> </CarouselContainer>
); );
}; };

View file

@ -158,6 +158,14 @@ const tableColumns: { [key: string]: ColDef } = {
params.data ? params.data.channels : undefined, params.data ? params.data.channels : undefined,
width: 100, width: 100,
}, },
codec: {
cellRenderer: (params: ICellRendererParams) => GenericCell(params, { position: 'center' }),
colId: TableColumn.CODEC,
headerName: i18n.t('table.column.codec'),
valueGetter: (params: ValueGetterParams) =>
params.data ? params.data.container : undefined,
width: 60,
},
comment: { comment: {
cellRenderer: NoteCell, cellRenderer: NoteCell,
colId: TableColumn.COMMENT, colId: TableColumn.COMMENT,

View file

@ -60,6 +60,10 @@ export const SONG_TABLE_COLUMNS = [
label: i18n.t('table.config.label.bitrate', { postProcess: 'titleCase' }), label: i18n.t('table.config.label.bitrate', { postProcess: 'titleCase' }),
value: TableColumn.BIT_RATE, value: TableColumn.BIT_RATE,
}, },
{
label: i18n.t('table.config.label.codec', { postProcess: 'titleCase' }),
value: TableColumn.CODEC,
},
{ {
label: i18n.t('table.config.label.lastPlayed', { postProcess: 'titleCase' }), label: i18n.t('table.config.label.lastPlayed', { postProcess: 'titleCase' }),
value: TableColumn.LAST_PLAYED, value: TableColumn.LAST_PLAYED,

View file

@ -522,7 +522,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
const handleUpdateRating = useCallback( const handleUpdateRating = useCallback(
(rating: number) => { (rating: number) => {
if (!ctx.dataNodes || !ctx.data) return; if (!ctx.dataNodes && !ctx.data) return;
let uniqueServerIds: string[] = []; let uniqueServerIds: string[] = [];
let items: AnyLibraryItems = []; let items: AnyLibraryItems = [];

View file

@ -89,8 +89,7 @@ export const PlayQueue = forwardRef(({ type }: QueueProps, ref: Ref<any>) => {
if (playbackType === PlaybackType.LOCAL) { if (playbackType === PlaybackType.LOCAL) {
mpvPlayer!.volume(volume); mpvPlayer!.volume(volume);
mpvPlayer!.setQueue(playerData); mpvPlayer!.setQueue(playerData, false);
mpvPlayer!.play();
} }
play(); play();

View file

@ -134,7 +134,7 @@ export const FullScreenPlayerImage = () => {
const albumArtRes = useSettingsStore((store) => store.general.albumArtRes); const albumArtRes = useSettingsStore((store) => store.general.albumArtRes);
const { queue } = usePlayerData(); const { queue } = usePlayerData();
const { opacity, useImageAspectRatio } = useFullScreenPlayerStore(); const { useImageAspectRatio } = useFullScreenPlayerStore();
const currentSong = queue.current; const currentSong = queue.current;
const { color: background } = useFastAverageColor({ const { color: background } = useFastAverageColor({
algorithm: 'dominant', algorithm: 'dominant',
@ -250,7 +250,6 @@ export const FullScreenPlayerImage = () => {
<MetadataContainer <MetadataContainer
className="full-screen-player-image-metadata" className="full-screen-player-image-metadata"
maw="100%" maw="100%"
opacity={opacity}
spacing="xs" spacing="xs"
> >
<TextTitle <TextTitle
@ -278,7 +277,6 @@ export const FullScreenPlayerImage = () => {
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: currentSong?.albumId || '', albumId: currentSong?.albumId || '',
})} })}
transform="uppercase"
w="100%" w="100%"
weight={600} weight={600}
> >
@ -292,7 +290,6 @@ export const FullScreenPlayerImage = () => {
style={{ style={{
textShadow: 'var(--fullscreen-player-text-shadow)', textShadow: 'var(--fullscreen-player-text-shadow)',
}} }}
transform="uppercase"
> >
{index > 0 && ( {index > 0 && (
<Text <Text
@ -313,7 +310,6 @@ export const FullScreenPlayerImage = () => {
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, { to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id, albumArtistId: artist.id,
})} })}
transform="uppercase"
weight={600} weight={600}
> >
{artist.name} {artist.name}

View file

@ -42,11 +42,11 @@ const HeaderItemWrapper = styled.div`
z-index: 2; z-index: 2;
`; `;
interface TransparendGridContainerProps { interface TransparentGridContainerProps {
opacity: number; opacity: number;
} }
const GridContainer = styled.div<TransparendGridContainerProps>` const GridContainer = styled.div<TransparentGridContainerProps>`
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
grid-template-columns: 1fr; grid-template-columns: 1fr;
@ -82,8 +82,6 @@ export const FullScreenPlayerQueue = () => {
}, },
]; ];
console.log('opacity', opacity);
return ( return (
<GridContainer <GridContainer
className="full-screen-player-queue-container" className="full-screen-player-queue-container"

View file

@ -153,7 +153,7 @@ const Controls = () => {
defaultValue={opacity} defaultValue={opacity}
label={(e) => `${e} %`} label={(e) => `${e} %`}
max={100} max={100}
min={1} min={0}
w="100%" w="100%"
onChangeEnd={(e) => setStore({ opacity: Number(e) })} onChangeEnd={(e) => setStore({ opacity: Number(e) })}
/> />

View file

@ -175,8 +175,7 @@ export const useHandlePlayQueueAdd = () => {
if (playType === Play.NOW || !hadSong) { if (playType === Play.NOW || !hadSong) {
mpvPlayer!.pause(); mpvPlayer!.pause();
mpvPlayer!.setQueue(playerData); mpvPlayer!.setQueue(playerData, false);
mpvPlayer!.play();
} else { } else {
mpvPlayer!.setQueueNext(playerData); mpvPlayer!.setQueueNext(playerData);
} }

View file

@ -8,6 +8,7 @@ import isElectron from 'is-electron';
import { nanoid } from 'nanoid/non-secure'; import { nanoid } from 'nanoid/non-secure';
import { AuthenticationResponse, ServerType } from '/@/renderer/api/types'; import { AuthenticationResponse, ServerType } from '/@/renderer/api/types';
import { useAuthStoreActions } from '/@/renderer/store'; import { useAuthStoreActions } from '/@/renderer/store';
import { ServerType, toServerType } from '/@/renderer/types';
import { api } from '/@/renderer/api'; import { api } from '/@/renderer/api';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -32,15 +33,27 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
legacyAuth: false, legacyAuth: false,
name: '', name: (localSettings ? localSettings.env.SERVER_NAME : window.SERVER_NAME) ?? '',
password: '', password: '',
savePassword: false, savePassword: false,
type: ServerType.JELLYFIN, type:
url: 'http://', (localSettings
? localSettings.env.SERVER_TYPE
: toServerType(window.SERVER_TYPE)) ?? ServerType.JELLYFIN,
url: (localSettings ? localSettings.env.SERVER_URL : window.SERVER_URL) ?? 'https://',
username: '', username: '',
}, },
}); });
// server lock for web is only true if lock is true *and* all other properties are set
const serverLock =
(localSettings
? !!localSettings.env.SERVER_LOCK
: !!window.SERVER_LOCK &&
window.SERVER_TYPE &&
window.SERVER_NAME &&
window.SERVER_URL) || false;
const isSubmitDisabled = !form.values.name || !form.values.url || !form.values.username; const isSubmitDisabled = !form.values.name || !form.values.url || !form.values.username;
const handleSubmit = form.onSubmit(async (values) => { const handleSubmit = form.onSubmit(async (values) => {
@ -61,7 +74,7 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
password: values.password, password: values.password,
username: values.username, username: values.username,
}, },
values.type, values.type as ServerType,
); );
if (!data) { if (!data) {
@ -75,7 +88,7 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
id: nanoid(), id: nanoid(),
name: values.name, name: values.name,
ndCredential: data.ndCredential, ndCredential: data.ndCredential,
type: values.type, type: values.type as ServerType,
url: values.url.replace(/\/$/, ''), url: values.url.replace(/\/$/, ''),
userId: data.userId, userId: data.userId,
username: data.username, username: data.username,
@ -116,11 +129,13 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
> >
<SegmentedControl <SegmentedControl
data={SERVER_TYPES} data={SERVER_TYPES}
disabled={serverLock}
{...form.getInputProps('type')} {...form.getInputProps('type')}
/> />
<Group grow> <Group grow>
<TextInput <TextInput
data-autofocus data-autofocus
disabled={serverLock}
label={t('form.addServer.input', { label={t('form.addServer.input', {
context: 'name', context: 'name',
postProcess: 'titleCase', postProcess: 'titleCase',
@ -128,6 +143,7 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
{...form.getInputProps('name')} {...form.getInputProps('name')}
/> />
<TextInput <TextInput
disabled={serverLock}
label={t('form.addServer.input', { label={t('form.addServer.input', {
context: 'url', context: 'url',
postProcess: 'titleCase', postProcess: 'titleCase',

View file

@ -131,6 +131,31 @@ export const WindowSettings = () => {
isHidden: !isElectron(), isHidden: !isElectron(),
title: t('setting.exitToTray', { postProcess: 'sentenceCase' }), title: t('setting.exitToTray', { postProcess: 'sentenceCase' }),
}, },
{
control: (
<Switch
aria-label="Toggle start in tray"
defaultChecked={settings.startMinimized}
disabled={!isElectron()}
onChange={(e) => {
if (!e) return;
localSettings?.set('window_start_minimized', e.currentTarget.checked);
setSettings({
window: {
...settings,
startMinimized: e.currentTarget.checked,
},
});
}}
/>
),
description: t('setting.startMinimized', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: !isElectron(),
title: t('setting.startMinimized', { postProcess: 'sentenceCase' }),
},
]; ];
return <SettingsSection options={windowOptions} />; return <SettingsSection options={windowOptions} />;

View file

@ -38,6 +38,9 @@ export const SidebarIcon = ({ active, route, size }: SidebarIconProps) => {
case AppRoute.LIBRARY_ALBUMS: case AppRoute.LIBRARY_ALBUMS:
if (active) return <RiAlbumFill size={size} />; if (active) return <RiAlbumFill size={size} />;
return <RiAlbumLine size={size} />; return <RiAlbumLine size={size} />;
case AppRoute.LIBRARY_ALBUM_ARTISTS:
if (active) return <RiUserVoiceFill size={size} />;
return <RiUserVoiceLine size={size} />;
case AppRoute.LIBRARY_ARTISTS: case AppRoute.LIBRARY_ARTISTS:
if (active) return <RiUserVoiceFill size={size} />; if (active) return <RiUserVoiceFill size={size} />;
return <RiUserVoiceLine size={size} />; return <RiUserVoiceLine size={size} />;

View file

@ -6,6 +6,9 @@
<meta http-equiv="Content-Security-Policy" /> <meta http-equiv="Content-Security-Policy" />
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Feishin</title> <title>Feishin</title>
<% if (web) { %>
<script src="settings.js"></script>
<% } %>
</head> </head>
<body> <body>

View file

@ -13,6 +13,10 @@ import { Browser } from '/@/main/preload/browser';
declare global { declare global {
interface Window { interface Window {
SERVER_LOCK?: boolean;
SERVER_NAME?: string;
SERVER_TYPE?: string;
SERVER_URL?: string;
electron: { electron: {
browser: Browser; browser: Browser;
discordRpc: DiscordRpc; discordRpc: DiscordRpc;

View file

@ -267,6 +267,7 @@ export interface SettingsState {
disableAutoUpdate: boolean; disableAutoUpdate: boolean;
exitToTray: boolean; exitToTray: boolean;
minimizeToTray: boolean; minimizeToTray: boolean;
startMinimized: boolean;
windowBarStyle: Platform; windowBarStyle: Platform;
}; };
} }
@ -575,6 +576,7 @@ const initialState: SettingsState = {
disableAutoUpdate: false, disableAutoUpdate: false,
exitToTray: false, exitToTray: false,
minimizeToTray: false, minimizeToTray: false,
startMinimized: false,
windowBarStyle: platformDefaultWindowBarStyle, windowBarStyle: platformDefaultWindowBarStyle,
}, },
}; };

View file

@ -54,6 +54,37 @@ export enum Platform {
WINDOWS = 'windows', WINDOWS = 'windows',
} }
export enum ServerType {
JELLYFIN = 'jellyfin',
NAVIDROME = 'navidrome',
SUBSONIC = 'subsonic',
}
export const toServerType = (value?: string): ServerType | null => {
switch (value?.toLowerCase()) {
case ServerType.JELLYFIN:
return ServerType.JELLYFIN;
case ServerType.NAVIDROME:
return ServerType.NAVIDROME;
default:
return null;
}
};
export type ServerListItem = {
credential: string;
features?: Record<string, number[]>;
id: string;
name: string;
ndCredential?: string;
savePassword?: boolean;
type: ServerType;
url: string;
userId: string | null;
username: string;
version?: string;
};
export enum PlayerStatus { export enum PlayerStatus {
PAUSED = 'paused', PAUSED = 'paused',
PLAYING = 'playing', PLAYING = 'playing',
@ -124,6 +155,7 @@ export enum TableColumn {
BIT_RATE = 'bitRate', BIT_RATE = 'bitRate',
BPM = 'bpm', BPM = 'bpm',
CHANNELS = 'channels', CHANNELS = 'channels',
CODEC = 'codec',
COMMENT = 'comment', COMMENT = 'comment',
DATE_ADDED = 'dateAdded', DATE_ADDED = 'dateAdded',
DISC_NUMBER = 'discNumber', DISC_NUMBER = 'discNumber',