├── public ├── CNAME ├── robots.txt ├── favicon.ico ├── manifest.json └── index.html ├── .prettierrc ├── Screenshot.png ├── .idea ├── .gitignore ├── modules.xml ├── inspectionProfiles │ └── Project_Default.xml └── misc.xml ├── src ├── setupTests.js ├── components │ └── KeyValue.js ├── index.css ├── App.test.js ├── app │ └── store.js ├── index.js ├── features │ ├── stats │ │ ├── statsSlice.js │ │ └── Stats.js │ ├── global │ │ └── globalSlice.js │ ├── settings │ │ ├── settingsSlice.js │ │ └── SettingsDialog.js │ └── query │ │ ├── querySlice.js │ │ └── Query.js ├── App.js ├── api │ └── api.js └── serviceWorker.js ├── beets-ui.iml ├── .gitignore ├── .github └── workflows │ └── react.js.yml ├── package.json └── README.md /public/CNAME: -------------------------------------------------------------------------------- 1 | beets-ui.cadel.me -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4 3 | } 4 | -------------------------------------------------------------------------------- /Screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kdelwat/beets-ui/HEAD/Screenshot.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kdelwat/beets-ui/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Datasource local storage ignored files 5 | /dataSources/ 6 | /dataSources.local.xml 7 | # Editor-based HTTP Client requests 8 | /httpRequests/ 9 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Beets UI", 3 | "name": "Beets UI", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /beets-ui.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/KeyValue.js: -------------------------------------------------------------------------------- 1 | import { Paragraph } from "evergreen-ui"; 2 | import React from "react"; 3 | 4 | export default function KeyVal({ label, value }) { 5 | return ( 6 | 7 | 8 | {label} 9 | 10 | 11 | {value || "Unknown"} 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | .idea 26 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import { Provider } from 'react-redux'; 4 | import store from './app/store'; 5 | import App from './App'; 6 | 7 | test('renders learn react link', () => { 8 | const { getByText } = render( 9 | 10 | 11 | 12 | ); 13 | 14 | expect(getByText(/learn/i)).toBeInTheDocument(); 15 | }); 16 | -------------------------------------------------------------------------------- /src/app/store.js: -------------------------------------------------------------------------------- 1 | import { configureStore } from "@reduxjs/toolkit"; 2 | import queryReducer from "../features/query/querySlice"; 3 | import statsReducer from "../features/stats/statsSlice"; 4 | import globalReducer from "../features/global/globalSlice"; 5 | import settingsReducer from "../features/settings/settingsSlice"; 6 | 7 | export default configureStore({ 8 | reducer: { 9 | query: queryReducer, 10 | stats: statsReducer, 11 | settings: settingsReducer, 12 | global: globalReducer, 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import store from './app/store'; 6 | import { Provider } from 'react-redux'; 7 | import * as serviceWorker from './serviceWorker'; 8 | 9 | ReactDOM.render( 10 | 11 | 12 | 13 | 14 | , 15 | document.getElementById('root') 16 | ); 17 | 18 | // If you want your app to work offline and load faster, you can change 19 | // unregister() to register() below. Note this comes with some pitfalls. 20 | // Learn more about service workers: https://bit.ly/CRA-PWA 21 | serviceWorker.unregister(); 22 | -------------------------------------------------------------------------------- /src/features/stats/statsSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | import Api from "../../api/api"; 3 | 4 | export const statsSlice = createSlice({ 5 | name: "stats", 6 | initialState: { results: null }, 7 | reducers: { 8 | resultsLoaded: (state, action) => { 9 | state.results = action.payload; 10 | }, 11 | }, 12 | }); 13 | 14 | export const { resultsLoaded } = statsSlice.actions; 15 | 16 | export const fetchResults = () => { 17 | return async (dispatch, getState) => { 18 | try { 19 | const results = await new Api(getState()).getStats(); 20 | 21 | dispatch(resultsLoaded(results)); 22 | } catch (err) { 23 | console.error(err); 24 | } 25 | }; 26 | }; 27 | 28 | export const selectResults = (state) => state.stats.results; 29 | 30 | export default statsSlice.reducer; 31 | -------------------------------------------------------------------------------- /src/features/stats/Stats.js: -------------------------------------------------------------------------------- 1 | import { useDispatch, useSelector } from "react-redux"; 2 | import { fetchResults, selectResults } from "./statsSlice"; 3 | import React, { useEffect } from "react"; 4 | import { Paragraph } from "evergreen-ui"; 5 | import KeyVal from "../../components/KeyValue"; 6 | 7 | // https://stackoverflow.com/a/58061735 8 | const useFetching = (someFetchActionCreator) => { 9 | const dispatch = useDispatch(); 10 | 11 | useEffect(() => { 12 | dispatch(someFetchActionCreator()); 13 | }, [dispatch, someFetchActionCreator]); 14 | }; 15 | 16 | export function Stats() { 17 | const results = useSelector(selectResults); 18 | 19 | useFetching(fetchResults); 20 | 21 | return !!results ? ( 22 |
23 | 24 | 25 |
26 | ) : ( 27 | Loading stats... 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/react.js.yml: -------------------------------------------------------------------------------- 1 | name: React CI/CD 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [14.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v1 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | 25 | - run: npm install 26 | - run: npm run build --if-present 27 | 28 | # From https://dev.to/dyarleniber/setting-up-a-ci-cd-workflow-on-github-actions-for-a-react-app-with-github-pages-and-codecov-4hnp 29 | - name: Deploy to GitHub Pages 30 | run: | 31 | git config --global user.name $user_name 32 | git config --global user.email $user_email 33 | git remote set-url origin https://${github_token}@github.com/${repository} 34 | npm run deploy 35 | env: 36 | user_name: 'github-actions[bot]' 37 | user_email: 'github-actions[bot]@users.noreply.github.com' 38 | github_token: ${{ secrets.GH_PAGES_DEPLOY_TOKEN }} 39 | repository: ${{ github.repository }} 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "beets-ui", 3 | "version": "0.1.0", 4 | "private": true, 5 | "homepage": "https://beets-ui.cadel.me", 6 | "dependencies": { 7 | "@reduxjs/toolkit": "^1.1.0", 8 | "@testing-library/jest-dom": "^4.2.4", 9 | "@testing-library/react": "^9.3.2", 10 | "@testing-library/user-event": "^7.1.2", 11 | "evergreen-ui": "^5.1.1", 12 | "gh-pages": "^3.1.0", 13 | "react": "^16.13.1", 14 | "react-dom": "^16.13.1", 15 | "react-redux": "^7.1.3", 16 | "react-scripts": "3.4.3", 17 | "source-map-explorer": "^2.5.0" 18 | }, 19 | "scripts": { 20 | "analyze": "source-map-explorer 'build/static/js/*.js'", 21 | "start": "react-scripts start", 22 | "build": "react-scripts build", 23 | "test": "react-scripts test", 24 | "eject": "react-scripts eject", 25 | "deploy": "gh-pages -d build" 26 | }, 27 | "eslintConfig": { 28 | "extends": "react-app" 29 | }, 30 | "browserslist": { 31 | "production": [ 32 | ">0.2%", 33 | "not dead", 34 | "not op_mini all" 35 | ], 36 | "development": [ 37 | "last 1 chrome version", 38 | "last 1 firefox version", 39 | "last 1 safari version" 40 | ] 41 | }, 42 | "devDependencies": { 43 | "prettier": "^2.1.2" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 16 | 17 | 26 | Beets UI 27 | 28 | 29 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Button, Heading, Pane } from "evergreen-ui"; 3 | import { Query } from "./features/query/Query"; 4 | import { Stats } from "./features/stats/Stats"; 5 | import { SettingsDialog } from "./features/settings/SettingsDialog"; 6 | import { useDispatch, useSelector } from "react-redux"; 7 | import { 8 | areSettingsPresent, 9 | shouldShowSettings, 10 | showSettingsDialog, 11 | } from "./features/global/globalSlice"; 12 | 13 | function App() { 14 | const showSettings = useSelector(shouldShowSettings); 15 | const settingsPresent = useSelector(areSettingsPresent); 16 | const dispatch = useDispatch(); 17 | 18 | return ( 19 | 25 | 33 | 34 | Beets UI 35 | 36 | 37 | {settingsPresent && } 38 | 44 | 45 | 46 | 47 | 48 | {settingsPresent && } 49 | 50 | 51 | 52 | 53 | ); 54 | } 55 | 56 | export default App; 57 | -------------------------------------------------------------------------------- /src/api/api.js: -------------------------------------------------------------------------------- 1 | export default class Api { 2 | constructor(state) { 3 | this.settings = state.global.settings.settings; 4 | } 5 | 6 | getAlbums = async (query) => { 7 | const url = isQuery(query) ? "/album/query/" + query : "/album"; 8 | 9 | return await this.makeRequest(url).then((res) => 10 | isQuery(query) ? res.results : res.albums 11 | ); 12 | }; 13 | 14 | deleteAlbums = async (ids) => { 15 | return await this.makeRequest( 16 | "/album/" + ids.map((id) => id.toString()).join(","), 17 | "DELETE" 18 | ); 19 | }; 20 | 21 | getTracks = async (query) => { 22 | const url = query && query !== "" ? "/item/query/" + query : "/item"; 23 | 24 | return await this.makeRequest(url).then((res) => 25 | isQuery(query) ? res.results : res.items 26 | ); 27 | }; 28 | 29 | deleteTracks = async (ids) => { 30 | return await this.makeRequest( 31 | "/item/" + ids.map((id) => id.toString()).join(","), 32 | "DELETE" 33 | ); 34 | }; 35 | 36 | getStats = async () => { 37 | return await this.makeRequest("/stats"); 38 | }; 39 | 40 | makeRequest = async (path, method) => { 41 | if (!this.settings) { 42 | throw new Error("Settings not yet initialised"); 43 | } 44 | 45 | method = method || "GET"; 46 | 47 | return fetch(this.settings.url + path, { 48 | method: method, 49 | headers: this.settings.basicAuth 50 | ? { 51 | Authorization: 52 | "Basic " + 53 | btoa( 54 | this.settings.basicAuth.username + 55 | ":" + 56 | this.settings.basicAuth.password 57 | ), 58 | } 59 | : {}, 60 | }).then((res) => res.json()); 61 | }; 62 | } 63 | 64 | const isQuery = (query) => query && query !== ""; 65 | -------------------------------------------------------------------------------- /src/features/global/globalSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | 3 | export const SettingsState = { 4 | PRESENT: "PRESENT", 5 | ABSENT: "ABSENT", 6 | }; 7 | 8 | export const globalSlice = createSlice({ 9 | name: "global", 10 | initialState: { settings: getSettings(), showSettings: false }, 11 | reducers: { 12 | settingsChanged: (state, action) => { 13 | state.settings = { 14 | state: SettingsState.PRESENT, 15 | settings: action.payload, 16 | }; 17 | state.showSettings = false; 18 | }, 19 | showSettingsDialog: (state) => { 20 | state.showSettings = true; 21 | }, 22 | hideSettingsDialog: (state) => { 23 | state.showSettings = false; 24 | }, 25 | }, 26 | }); 27 | 28 | export const { 29 | settingsChanged, 30 | showSettingsDialog, 31 | hideSettingsDialog, 32 | } = globalSlice.actions; 33 | export const areSettingsPresent = (state) => 34 | state.global.settings.state === SettingsState.PRESENT; 35 | 36 | export const shouldShowSettings = (state) => 37 | state.global.settings.state === SettingsState.ABSENT || 38 | state.global.showSettings; 39 | 40 | export default globalSlice.reducer; 41 | 42 | export const saveSettings = (settings) => { 43 | return async (dispatch) => { 44 | try { 45 | storeSettings({ settings }); 46 | 47 | dispatch(settingsChanged(settings)); 48 | } catch (err) { 49 | console.error(err); 50 | } 51 | }; 52 | }; 53 | 54 | // Store to/from local storage 55 | export function getSettings() { 56 | const settingsJSON = localStorage.getItem("beets:settings"); 57 | 58 | if (!settingsJSON) { 59 | return { state: SettingsState.ABSENT, settings: null }; 60 | } else { 61 | return { 62 | state: SettingsState.PRESENT, 63 | settings: JSON.parse(settingsJSON), 64 | }; 65 | } 66 | } 67 | 68 | function storeSettings(settings) { 69 | localStorage.setItem("beets:settings", JSON.stringify(settings.settings)); 70 | } 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Beets UI 2 | 3 | A web interface for managing a [Beets](https://beets.io/) music library. 4 | 5 | ![Screenshot](./Screenshot.png) 6 | 7 | Features: 8 | * Query albums and tracks in your library using Beets query syntax 9 | * View album and track metadata 10 | * Delete music from your library 11 | * Completely client-side and self-hostable 12 | * Supports basic authentication and HTTPS for communicating with Beets securely 13 | 14 | ## For users 15 | 16 | Beets UI is a simple client-side webapp. When you first use it, you will be asked to provide the URL of a running instance of the Beets web API, which you need to host yourself. 17 | 18 | You can access this webapp at [beets-ui.cadel.me](https://beets-ui.cadel.me/). 19 | 20 | ### Tips for hosting the API 21 | 22 | To enable the Beets web API on the default port, use this Beets config: 23 | 24 | ```yaml 25 | --- 26 | plugins: web 27 | web: 28 | cors: '*' 29 | reverse_proxy: true 30 | ``` 31 | 32 | #### Nginx reverse proxy 33 | 34 | If you want to host the web API via a reverse proxy with authentication, I would recommend using Nginx. 35 | 36 | The sample server config below uses basic authentication and LetsEncrypt for HTTPS - you will need to adapt it for your 37 | use case. 38 | 39 | ``` 40 | upstream beets { 41 | server 127.0.0.1:8337; 42 | keepalive 64; 43 | } 44 | 45 | server { 46 | listen 443 ssl; 47 | 48 | server_name beets.domain.com; 49 | 50 | location / { 51 | auth_basic "Log in to Beets UI"; 52 | auth_basic_user_file /etc/nginx/.htpasswd; 53 | 54 | client_max_body_size 100M; 55 | 56 | # From https://enable-cors.org/server_nginx.html 57 | if ($request_method = 'OPTIONS') { 58 | add_header 'Access-Control-Allow-Origin' '*'; 59 | add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PATCH, DELETE'; 60 | add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization'; 61 | add_header 'Access-Control-Max-Age' 1728000; 62 | add_header 'Content-Type' 'text/plain; charset=utf-8'; 63 | add_header 'Content-Length' 0; 64 | return 204; 65 | } 66 | 67 | proxy_set_header X-Forwarded-Host $host; 68 | proxy_set_header X-Forwarded-Server $host; 69 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 70 | proxy_pass http://beets; 71 | proxy_http_version 1.1; 72 | proxy_pass_request_headers on; 73 | proxy_set_header Connection "keep-alive"; 74 | proxy_store off; 75 | } 76 | 77 | ssl_certificate /etc/letsencrypt/live/beets.domain.com/fullchain.pem; 78 | ssl_certificate_key /etc/letsencrypt/live/beets.domain.com/privkey.pem; 79 | 80 | ssl_session_timeout 1d; 81 | ssl_session_cache shared:SSL:50m; 82 | ssl_session_tickets off; 83 | 84 | ssl_dhparam /etc/ssl/certs/dhparam.pem; 85 | 86 | ssl_protocols TLSv1.2 TLSv1.3; 87 | ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; 88 | ssl_prefer_server_ciphers off; 89 | } 90 | ``` 91 | 92 | ## For self-hosters 93 | 94 | To self host the webapp, follow the build instructions in the "For developers section" and then read the 95 | [Deployment guide](https://create-react-app.dev/docs/deployment) from the `create-react-app` docs. 96 | 97 | All you really need to do is statically serve the files generated into the `build` folder. 98 | 99 | ## For developers 100 | 101 | In the project directory, you can run: 102 | 103 | ### `yarn start` 104 | 105 | Runs the app in the development mode.
106 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 107 | 108 | The page will reload if you make edits.
109 | You will also see any lint errors in the console. 110 | 111 | ### `yarn build` 112 | 113 | Builds the app for production to the `build` folder.
114 | It correctly bundles React in production mode and optimizes the build for the best performance. 115 | 116 | The build is minified and the filenames include the hashes.
117 | Your app is ready to be deployed! 118 | -------------------------------------------------------------------------------- /src/features/settings/settingsSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | import { getSettings } from "../global/globalSlice"; 3 | import Api from "../../api/api"; 4 | 5 | export const SettingsTestStatus = { 6 | SUCCESS: "SUCCESS", 7 | FAILED: "FAILED", 8 | NOT_RUN: "NOT_RUN", 9 | }; 10 | 11 | export const settingsSlice = createSlice({ 12 | name: "settings", 13 | initialState: { 14 | url: "", 15 | basicAuthEnabled: false, 16 | username: "", 17 | password: "", 18 | test: { status: SettingsTestStatus.NOT_RUN }, 19 | }, 20 | reducers: { 21 | changeUrl: (state, action) => { 22 | state.url = action.payload; 23 | state.test = { status: SettingsTestStatus.NOT_RUN }; 24 | }, 25 | changeBasicAuthEnabled: (state, action) => { 26 | state.basicAuthEnabled = action.payload; 27 | state.test = { status: SettingsTestStatus.NOT_RUN }; 28 | }, 29 | changeUsername: (state, action) => { 30 | state.username = action.payload; 31 | state.test = { status: SettingsTestStatus.NOT_RUN }; 32 | }, 33 | changePassword: (state, action) => { 34 | state.password = action.payload; 35 | state.test = { status: SettingsTestStatus.NOT_RUN }; 36 | }, 37 | completedTest: (state, action) => { 38 | if (action.payload.result) { 39 | state.test = { status: SettingsTestStatus.SUCCESS }; 40 | } else { 41 | state.test = { 42 | status: SettingsTestStatus.FAILED, 43 | error: action.payload.err, 44 | }; 45 | } 46 | }, 47 | }, 48 | }); 49 | 50 | export const loadSettings = () => { 51 | return async (dispatch) => { 52 | try { 53 | const settings = getSettings(); 54 | 55 | dispatch(changeUrl(settings.settings.url)); 56 | 57 | if (settings.settings.basicAuth) { 58 | dispatch(changeBasicAuthEnabled(true)); 59 | dispatch(changePassword(settings.settings.password)); 60 | dispatch(changeUsername(settings.settings.username)); 61 | } else { 62 | dispatch(changeBasicAuthEnabled(false)); 63 | } 64 | } catch (err) { 65 | console.error(err); 66 | } 67 | }; 68 | }; 69 | 70 | export const { 71 | changeUrl, 72 | changeBasicAuthEnabled, 73 | changeUsername, 74 | changePassword, 75 | completedTest, 76 | } = settingsSlice.actions; 77 | 78 | export const isBasicAuthEnabled = (state) => state.settings.basicAuthEnabled; 79 | 80 | export const selectWipSettings = (state) => { 81 | return { 82 | url: state.settings.url, 83 | basicAuthEnabled: state.settings.basicAuthEnabled, 84 | username: state.settings.username, 85 | password: state.settings.password, 86 | }; 87 | }; 88 | 89 | export const testNewSettings = () => { 90 | return async (dispatch, getState) => { 91 | try { 92 | const newSettings = selectNewSettings(getState()); 93 | 94 | const api = new Api({ 95 | global: { settings: { settings: newSettings } }, 96 | }); 97 | 98 | const stats = await api.getStats(); 99 | 100 | if (stats) { 101 | dispatch(completedTest({ result: true })); 102 | } else { 103 | dispatch( 104 | completedTest({ result: false, err: "Could not connect" }) 105 | ); 106 | } 107 | } catch (err) { 108 | console.error(err); 109 | 110 | dispatch(completedTest({ result: false, err: err.message })); 111 | } 112 | }; 113 | }; 114 | 115 | export const selectNewSettings = (state) => { 116 | if (state.settings.url === "") { 117 | return null; 118 | } 119 | 120 | if (state.settings.basicAuthEnabled && state.settings.username === "") { 121 | return null; 122 | } 123 | 124 | return { 125 | url: state.settings.url, 126 | basicAuth: state.settings.basicAuthEnabled 127 | ? { 128 | username: state.settings.username, 129 | password: state.settings.password, 130 | } 131 | : null, 132 | }; 133 | }; 134 | 135 | export const selectTestStatus = (state) => { 136 | return state.settings.test; 137 | }; 138 | 139 | export default settingsSlice.reducer; 140 | -------------------------------------------------------------------------------- /src/features/settings/SettingsDialog.js: -------------------------------------------------------------------------------- 1 | import { useDispatch, useSelector } from "react-redux"; 2 | import { 3 | BanCircleIcon, 4 | Button, 5 | Checkbox, 6 | Dialog, 7 | Pane, 8 | Paragraph, 9 | TextInputField, 10 | TickCircleIcon, 11 | } from "evergreen-ui"; 12 | import React from "react"; 13 | import { 14 | changeBasicAuthEnabled, 15 | changePassword, 16 | changeUrl, 17 | changeUsername, 18 | isBasicAuthEnabled, 19 | loadSettings, 20 | selectNewSettings, 21 | selectTestStatus, 22 | selectWipSettings, 23 | SettingsTestStatus, 24 | testNewSettings, 25 | } from "./settingsSlice"; 26 | import { hideSettingsDialog, saveSettings } from "../global/globalSlice"; 27 | 28 | export function SettingsDialog({ isShown, canClose }) { 29 | const wipSettings = useSelector(selectWipSettings); 30 | const validSettings = useSelector(selectNewSettings); 31 | const basicAuthEnabled = useSelector(isBasicAuthEnabled); 32 | const testStatus = useSelector(selectTestStatus); 33 | 34 | const dispatch = useDispatch(); 35 | 36 | return ( 37 | dispatch(saveSettings(validSettings))} 48 | onOpenComplete={() => dispatch(loadSettings())} 49 | onCloseComplete={() => dispatch(hideSettingsDialog())} 50 | > 51 |
52 | 58 | dispatch(changeUrl(event.target.value)) 59 | } 60 | /> 61 | 62 | 65 | dispatch(changeBasicAuthEnabled(e.target.checked)) 66 | } 67 | label="Enable HTTP basic authentication?" 68 | /> 69 | 70 | {basicAuthEnabled && ( 71 | 77 | dispatch(changeUsername(event.target.value)) 78 | } 79 | /> 80 | )} 81 | 82 | {basicAuthEnabled && ( 83 | 89 | dispatch(changePassword(event.target.value)) 90 | } 91 | /> 92 | )} 93 |
94 | 95 | 102 | 103 | 104 |
105 | ); 106 | } 107 | 108 | function TestResult({ status }) { 109 | switch (status.status) { 110 | case SettingsTestStatus.SUCCESS: 111 | return ( 112 | 113 | 114 | 115 | Connection successful 116 | 117 | ); 118 | case SettingsTestStatus.FAILED: 119 | return ( 120 | 121 | 122 | 123 | Connection failed: {status.error} 124 | 125 | ); 126 | case SettingsTestStatus.NOT_RUN: 127 | default: 128 | return ; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl, { 104 | headers: { 'Service-Worker': 'script' }, 105 | }) 106 | .then(response => { 107 | // Ensure service worker exists, and that we really are getting a JS file. 108 | const contentType = response.headers.get('content-type'); 109 | if ( 110 | response.status === 404 || 111 | (contentType != null && contentType.indexOf('javascript') === -1) 112 | ) { 113 | // No service worker found. Probably a different app. Reload the page. 114 | navigator.serviceWorker.ready.then(registration => { 115 | registration.unregister().then(() => { 116 | window.location.reload(); 117 | }); 118 | }); 119 | } else { 120 | // Service worker found. Proceed as normal. 121 | registerValidSW(swUrl, config); 122 | } 123 | }) 124 | .catch(() => { 125 | console.log( 126 | 'No internet connection found. App is running in offline mode.' 127 | ); 128 | }); 129 | } 130 | 131 | export function unregister() { 132 | if ('serviceWorker' in navigator) { 133 | navigator.serviceWorker.ready.then(registration => { 134 | registration.unregister(); 135 | }); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/features/query/querySlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | import Api from "../../api/api"; 3 | 4 | export const QueryType = { 5 | QUERY_ALBUMS: "QUERY_ALBUMS", 6 | QUERY_TRACKS: "QUERY_TRACKS", 7 | }; 8 | 9 | export const QueryState = { 10 | NOT_RUN: "NOT_RUN", 11 | LOADING: "LOADING", 12 | ERROR: "ERROR", 13 | SUCCESS: "SUCCESS", 14 | }; 15 | 16 | export const querySlice = createSlice({ 17 | name: "query", 18 | initialState: { 19 | queryState: { 20 | state: QueryState.LOADING, 21 | warnings: [], 22 | resultType: QueryType.QUERY_ALBUMS, 23 | }, 24 | beetsQuery: "", 25 | nextQueryType: QueryType.QUERY_ALBUMS, 26 | resultSelected: null, 27 | filterString: null, 28 | deleteOnDisk: true, 29 | }, 30 | reducers: { 31 | resultsLoaded: (state, action) => { 32 | state.queryState = { 33 | state: QueryState.SUCCESS, 34 | results: action.payload, 35 | resultType: state.nextQueryType, 36 | }; 37 | }, 38 | resultsDeleted: (state, action) => { 39 | state.queryState.results = state.queryState.results.filter( 40 | (r) => !action.payload.includes(r.id) 41 | ); 42 | }, 43 | clearQuery: (state) => { 44 | state.resultSelected = null; 45 | state.filterString = null; 46 | state.queryState = { state: QueryState.NOT_RUN }; 47 | }, 48 | loadError: (state, action) => { 49 | state.queryState = { 50 | state: QueryState.ERROR, 51 | error: action.payload, 52 | }; 53 | }, 54 | startLoading: (state, action) => { 55 | state.queryState = { 56 | state: QueryState.LOADING, 57 | warnings: 58 | state.beetsQuery === "" 59 | ? [ 60 | "This query may take a long time. Try making it more specific before searching.", 61 | ] 62 | : [], 63 | }; 64 | }, 65 | changeFilterString: (state, action) => { 66 | state.filterString = action.payload === "" ? null : action.payload; 67 | }, 68 | changeNextQueryType: (state, action) => { 69 | state.nextQueryType = action.payload; 70 | }, 71 | changeBeetsQuery: (state, action) => { 72 | state.beetsQuery = action.payload; 73 | }, 74 | changeResultSelected: (state, action) => { 75 | state.resultSelected = action.payload; 76 | }, 77 | changeDeleteOnDisk: (state, action) => { 78 | state.deleteOnDisk = action.payload; 79 | }, 80 | }, 81 | }); 82 | 83 | export const { 84 | resultsLoaded, 85 | resultsDeleted, 86 | loadError, 87 | clearQuery, 88 | changeFilterString, 89 | changeNextQueryType, 90 | changeBeetsQuery, 91 | changeResultSelected, 92 | changeDeleteOnDisk, 93 | startLoading, 94 | } = querySlice.actions; 95 | 96 | // Thunks 97 | 98 | export const fetchResults = () => { 99 | return async (dispatch, getState) => { 100 | try { 101 | dispatch(startLoading()); 102 | 103 | const state = getState(); 104 | const api = new Api(state); 105 | 106 | let results; 107 | if (state.query.nextQueryType === QueryType.QUERY_ALBUMS) { 108 | results = await api.getAlbums(state.query.beetsQuery); 109 | } else { 110 | results = await api.getTracks(state.query.beetsQuery); 111 | } 112 | 113 | dispatch(resultsLoaded(results)); 114 | } catch (err) { 115 | console.error(err); 116 | 117 | dispatch(loadError(err.message)); 118 | } 119 | }; 120 | }; 121 | 122 | export const deleteResults = () => { 123 | return async (dispatch, getState) => { 124 | try { 125 | const state = getState(); 126 | const api = new Api(state); 127 | 128 | const idsToDelete = state.query.queryState.results.map((r) => r.id); 129 | 130 | let result; 131 | if (state.query.nextQueryType === QueryType.QUERY_ALBUMS) { 132 | result = await api.deleteAlbums( 133 | idsToDelete, 134 | state.query.deleteOnDisk 135 | ); 136 | } else { 137 | result = await api.deleteTracks( 138 | idsToDelete, 139 | state.query.deleteOnDisk 140 | ); 141 | } 142 | 143 | if (result.deleted) { 144 | dispatch(resultsDeleted(idsToDelete)); 145 | } else { 146 | dispatch(loadError("Could not delete results of query")); 147 | } 148 | } catch (err) { 149 | console.error(err); 150 | 151 | dispatch(loadError(err.message)); 152 | } 153 | }; 154 | }; 155 | 156 | // Selectors 157 | export const selectResults = (state) => { 158 | if (state.query.queryState.state !== QueryState.SUCCESS) { 159 | return null; 160 | } 161 | 162 | return state.query.filterString 163 | ? state.query.queryState.results.filter((r) => 164 | applyFilterString( 165 | r, 166 | state.query.filterString.toLowerCase(), 167 | state.query.queryState.resultType 168 | ) 169 | ) 170 | : state.query.queryState.results; 171 | }; 172 | 173 | const applyFilterString = (result, filterString, resultType) => { 174 | switch (resultType) { 175 | case QueryType.QUERY_ALBUMS: 176 | return ( 177 | result.album.toLowerCase().includes(filterString) || 178 | result.albumartist.toLowerCase().includes(filterString) 179 | ); 180 | case QueryType.QUERY_TRACKS: 181 | default: 182 | return ( 183 | result.album.toLowerCase().includes(filterString) || 184 | result.artist.toLowerCase().includes(filterString) || 185 | result.title.toLowerCase().includes(filterString) 186 | ); 187 | } 188 | }; 189 | export const selectQueryState = (state) => state.query.queryState; 190 | export const selectQueryType = (state) => state.query.queryState.resultType; 191 | export const selectNextQueryType = (state) => state.query.nextQueryType; 192 | export const selectBeetsQuery = (state) => state.query.beetsQuery; 193 | export const selectChosenResult = (state) => 194 | state.query.queryState.state === QueryState.SUCCESS 195 | ? state.query.queryState.results.find( 196 | (r) => r.id === state.query.resultSelected 197 | ) 198 | : null; 199 | 200 | export default querySlice.reducer; 201 | -------------------------------------------------------------------------------- /src/features/query/Query.js: -------------------------------------------------------------------------------- 1 | import { useDispatch, useSelector } from "react-redux"; 2 | import React, { Fragment, useEffect } from "react"; 3 | import { 4 | Alert, 5 | Button, 6 | Checkbox, 7 | Dialog, 8 | Heading, 9 | Pane, 10 | SearchInput, 11 | Select, 12 | Table, 13 | } from "evergreen-ui"; 14 | import { 15 | changeBeetsQuery, 16 | changeDeleteOnDisk, 17 | changeFilterString, 18 | changeNextQueryType, 19 | changeResultSelected, 20 | clearQuery, 21 | deleteResults, 22 | fetchResults, 23 | QueryState, 24 | QueryType, 25 | selectBeetsQuery, 26 | selectChosenResult, 27 | selectNextQueryType, 28 | selectQueryState, 29 | selectQueryType, 30 | selectResults, 31 | } from "./querySlice"; 32 | import KeyVal from "../../components/KeyValue"; 33 | 34 | // https://stackoverflow.com/a/58061735 35 | const useFetching = (someFetchActionCreator) => { 36 | const dispatch = useDispatch(); 37 | 38 | useEffect(() => { 39 | dispatch(someFetchActionCreator()); 40 | }, [dispatch, someFetchActionCreator]); 41 | }; 42 | 43 | export function Query() { 44 | const results = useSelector(selectResults); 45 | const queryState = useSelector(selectQueryState); 46 | const queryType = useSelector(selectQueryType); 47 | const nextQueryType = useSelector(selectNextQueryType); 48 | const beetsQuery = useSelector(selectBeetsQuery); 49 | const chosenResult = useSelector(selectChosenResult); 50 | const deleteOnDisk = useSelector((state) => state.query.deleteOnDisk); 51 | 52 | const dispatch = useDispatch(); 53 | 54 | useFetching(fetchResults); 55 | 56 | return ( 57 | 58 | 59 | 70 | 71 | 75 | dispatch(changeBeetsQuery(event.target.value)) 76 | } 77 | marginRight={8} 78 | /> 79 | 86 | 87 | 88 |
89 | 90 | {queryState.state === QueryState.SUCCESS && ( 91 | 92 | 96 | 97 | 102 | 108 | 113 | 120 | 121 | 125 | dispatch( 126 | changeDeleteOnDisk(e.target.checked) 127 | ) 128 | } 129 | /> 130 | 131 | 132 | 133 | )} 134 | 135 | 136 | 137 | 138 |
139 | ); 140 | } 141 | 142 | function QueryResult({ queryState }) { 143 | switch (queryState.state) { 144 | case QueryState.LOADING: 145 | return ( 146 | 147 | 148 | {queryState.warnings.map((warn, key) => ( 149 | 155 | ))} 156 | 157 | ); 158 | case QueryState.ERROR: 159 | return ; 160 | case QueryState.SUCCESS: 161 | return ( 162 | 163 | 170 | 171 | 172 | {queryState.results.map((album) => 173 | queryState.resultType === QueryType.QUERY_ALBUMS ? ( 174 | 175 | ) : ( 176 | 177 | ) 178 | )} 179 | 180 |
181 | ); 182 | case QueryState.NOT_RUN: 183 | default: 184 | return ( 185 | 189 | ); 190 | } 191 | } 192 | 193 | function TableHeader({ labels }) { 194 | const dispatch = useDispatch(); 195 | 196 | return ( 197 | 198 | dispatch(changeFilterString(value))} 200 | /> 201 | {labels.map((l) => ( 202 | {l} 203 | ))} 204 | 205 | ); 206 | } 207 | 208 | function AlbumRow({ album }) { 209 | const dispatch = useDispatch(); 210 | 211 | return ( 212 | dispatch(changeResultSelected(album.id))} 215 | > 216 | {album.album} 217 | {album.albumartist} 218 | {album.year} 219 | 220 | ); 221 | } 222 | 223 | function TrackRow({ track }) { 224 | const dispatch = useDispatch(); 225 | 226 | return ( 227 | dispatch(changeResultSelected(track.id))} 230 | > 231 | {track.title} 232 | {track.artist} 233 | {track.album} 234 | {track.year} 235 | 236 | ); 237 | } 238 | 239 | function ResultDialog({ queryType, result }) { 240 | const dispatch = useDispatch(); 241 | 242 | return ( 243 | dispatch(changeResultSelected(null))} 247 | confirmLabel="Done" 248 | > 249 | {!!result ? ( 250 | queryType === QueryType.QUERY_ALBUMS ? ( 251 |
252 | 253 | {result.album} ({result.year}) 254 | 255 | 256 | {result.albumartist} 257 | 258 | 259 | 260 | 261 |
262 | ) : ( 263 |
264 | 265 | {result.title} ({result.year}) 266 | 267 | 268 | {result.artist}, {result.album} 269 | 270 | 271 | 272 | 273 | 274 |
275 | ) 276 | ) : ( 277 |
278 | )} 279 |
280 | ); 281 | } 282 | --------------------------------------------------------------------------------