├── backend ├── tests │ ├── __init__.py │ ├── mixins │ │ ├── __init__.py │ │ └── plugins.py │ ├── unit │ │ ├── test_database │ │ │ ├── conftest.py │ │ │ ├── test_setup.py │ │ │ ├── test_dates.py │ │ │ └── test_models_state.py │ │ ├── test_setup.py │ │ ├── test_importer │ │ │ ├── test_stages.py │ │ │ ├── test_session.py │ │ │ └── test_progress.py │ │ └── test_redis.py │ ├── data │ │ └── audio │ │ │ ├── 1991 │ │ │ └── Chant [SINGLE] │ │ │ │ └── 01 Chant.mp3 │ │ │ ├── 1991.zip │ │ │ ├── test.mp3 │ │ │ ├── cover.png │ │ │ ├── multi_flat │ │ │ ├── 01 Chant.mp3 │ │ │ └── 01 Antidote.mp3 │ │ │ ├── Annix │ │ │ └── Antidote │ │ │ │ └── 01 Antidote.mp3 │ │ │ └── multi │ │ │ ├── Antidote │ │ │ └── 01 Antidote.mp3 │ │ │ └── Chant [SINGLE] │ │ │ └── 01 Chant.mp3 │ └── integration │ │ ├── test_routes │ │ └── test_config.py │ │ └── test_websocket │ │ ├── test_errors.py │ │ └── conftest.py ├── beets_flask │ ├── __init__.py │ ├── importer │ │ └── __init__.py │ ├── invoker │ │ ├── __init__.py │ │ └── job.py │ ├── database │ │ ├── __init__.py │ │ └── models │ │ │ ├── __init__.py │ │ │ └── types.py │ ├── config │ │ ├── __init__.py │ │ ├── config_bf_default.yaml │ │ └── config_bf_example.yaml │ ├── server │ │ ├── routes │ │ │ ├── db_models │ │ │ │ ├── __init__.py │ │ │ │ └── folder.py │ │ │ ├── __init__.py │ │ │ ├── frontend.py │ │ │ ├── library │ │ │ │ ├── __init__.py │ │ │ │ ├── stats.py │ │ │ │ └── metadata.py │ │ │ └── monitor.py │ │ ├── websocket │ │ │ ├── __init__.py │ │ │ └── errors.py │ │ └── app.py │ ├── redis.py │ ├── logger.py │ ├── utility.py │ └── dirhash_custom.py ├── launch_db_init.py ├── launch_watchdog_worker.py ├── launch_redis_workers.py └── generate_types.py ├── docs ├── changelog.md ├── _static │ └── custom.css ├── _templates │ ├── base.rst │ ├── base.html │ ├── class.rst │ └── module.rst ├── develop │ └── resources │ │ ├── index.md │ │ ├── documentation.md │ │ ├── docker.md │ │ ├── macos.md │ │ ├── backend.md │ │ └── frontend.md ├── Makefile ├── index.md ├── .readthedocs.yaml ├── limitations.md ├── faq.md ├── plugins.md └── conf.py ├── frontend ├── public │ ├── logo_beets.png │ └── logo_flask.png ├── src │ ├── assets │ │ ├── spotify.png │ │ ├── broken-record.png │ │ ├── missing_cover.webp │ │ ├── spotifyBw.svg │ │ ├── unmatched_tracks.svg │ │ ├── unmatched_items.svg │ │ └── importAuto.svg │ ├── components │ │ ├── common │ │ │ ├── debugging │ │ │ │ ├── typing.ts │ │ │ │ ├── json.tsx │ │ │ │ └── events.ts │ │ │ ├── link.tsx │ │ │ ├── table │ │ │ │ ├── index.tsx │ │ │ │ ├── ViewToggle.tsx │ │ │ │ └── Readme.md │ │ │ ├── units │ │ │ │ └── bytes.ts │ │ │ ├── strings.tsx │ │ │ ├── page.tsx │ │ │ ├── inputs │ │ │ │ ├── copy.tsx │ │ │ │ ├── mutationButton.tsx │ │ │ │ ├── search.tsx │ │ │ │ └── back.tsx │ │ │ ├── hooks │ │ │ │ ├── useDebounce.ts │ │ │ │ ├── useSwipe.ts │ │ │ │ ├── useMobileSafeContextMenu.ts │ │ │ │ ├── useQueryParamsState.ts │ │ │ │ └── useMediaSession.ts │ │ │ ├── browser │ │ │ │ ├── loading.tsx │ │ │ │ └── artists.tsx │ │ │ ├── websocket │ │ │ │ └── useSocket.tsx │ │ │ └── navigation.tsx │ │ ├── inbox │ │ │ ├── cards │ │ │ │ └── common.tsx │ │ │ ├── actions │ │ │ │ └── descriptions.tsx │ │ │ └── folderSelectionContext.tsx │ │ ├── library │ │ │ ├── links.tsx │ │ │ ├── audio │ │ │ │ └── utils.tsx │ │ │ └── sources.ts │ │ ├── import │ │ │ └── icons.tsx │ │ └── frontpage │ │ │ └── card.tsx │ ├── vite-env.d.ts │ ├── routes │ │ ├── terminal │ │ │ └── index.tsx │ │ ├── library │ │ │ ├── (resources) │ │ │ │ ├── item.$itemId.identifier.tsx │ │ │ │ ├── album.$albumId.index.tsx │ │ │ │ ├── item.$itemId.beetsdata.tsx │ │ │ │ ├── album.$albumId.beetsdata.tsx │ │ │ │ └── album.$albumId.identifier.tsx │ │ │ └── browse │ │ │ │ └── artists.route.tsx │ │ ├── debug │ │ │ ├── error.tsx │ │ │ ├── jobs.tsx │ │ │ ├── index.tsx │ │ │ └── audio.tsx │ │ ├── inbox │ │ │ ├── task.$taskId.tsx │ │ │ └── folder.$path.tsx │ │ └── sessiondraft │ │ │ └── index.tsx │ ├── api │ │ ├── dbfolder.ts │ │ ├── monitor.ts │ │ └── websocket.ts │ └── main.css ├── prettier.config.js ├── index.html ├── tsconfig.json ├── vite.config.ts └── package.json ├── .git-blame-ignore-revs ├── .pre-commit-config.yaml ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ ├── bug_report.md │ └── help-wanted.md └── workflows │ ├── changelog_reminder.yml │ ├── python.yml │ ├── javascript.yml │ └── docker_hub.yml ├── docker ├── docker-compose.tests.yaml ├── entrypoints │ ├── entrypoint_user_scripts.sh │ ├── common.sh │ ├── entrypoint_fix_permissions.sh │ ├── entrypoint.sh │ ├── entrypoint_dev.sh │ └── entrypoint_add_groups.sh ├── docker-compose.yaml └── docker-compose.dev.yaml ├── .dockerignore └── LICENSE /backend/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/tests/mixins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/tests/unit/test_database/conftest.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | ```{include} ../CHANGELOG.md 2 | ``` -------------------------------------------------------------------------------- /backend/beets_flask/__init__.py: -------------------------------------------------------------------------------- 1 | from .logger import log 2 | 3 | __all__ = ["log"] 4 | -------------------------------------------------------------------------------- /frontend/public/logo_beets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pSpitzner/beets-flask/HEAD/frontend/public/logo_beets.png -------------------------------------------------------------------------------- /frontend/public/logo_flask.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pSpitzner/beets-flask/HEAD/frontend/public/logo_flask.png -------------------------------------------------------------------------------- /backend/tests/data/audio/1991.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pSpitzner/beets-flask/HEAD/backend/tests/data/audio/1991.zip -------------------------------------------------------------------------------- /backend/tests/data/audio/test.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pSpitzner/beets-flask/HEAD/backend/tests/data/audio/test.mp3 -------------------------------------------------------------------------------- /frontend/src/assets/spotify.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pSpitzner/beets-flask/HEAD/frontend/src/assets/spotify.png -------------------------------------------------------------------------------- /backend/tests/data/audio/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pSpitzner/beets-flask/HEAD/backend/tests/data/audio/cover.png -------------------------------------------------------------------------------- /docs/_static/custom.css: -------------------------------------------------------------------------------- 1 | .table-wrapper table { 2 | width: 100%; 3 | } 4 | 5 | .icon { 6 | vertical-align: middle; 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/assets/broken-record.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pSpitzner/beets-flask/HEAD/frontend/src/assets/broken-record.png -------------------------------------------------------------------------------- /frontend/src/assets/missing_cover.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pSpitzner/beets-flask/HEAD/frontend/src/assets/missing_cover.webp -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # PyUpgrade 2 | d7fdc2e24eec7e5b58585ea2a82509ffd3283ad0 3 | # Prettier formatting 4 | 15d9ca8c5b430e61c11d0a59cf507bba56ccad4d -------------------------------------------------------------------------------- /backend/tests/data/audio/multi_flat/01 Chant.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pSpitzner/beets-flask/HEAD/backend/tests/data/audio/multi_flat/01 Chant.mp3 -------------------------------------------------------------------------------- /docs/_templates/base.rst: -------------------------------------------------------------------------------- 1 | {{ fullname | escape | underline}} 2 | 3 | .. currentmodule:: {{ module }} 4 | 5 | .. auto{{ objtype }}:: {{ objname }} 6 | -------------------------------------------------------------------------------- /backend/tests/data/audio/multi_flat/01 Antidote.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pSpitzner/beets-flask/HEAD/backend/tests/data/audio/multi_flat/01 Antidote.mp3 -------------------------------------------------------------------------------- /backend/tests/data/audio/Annix/Antidote/01 Antidote.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pSpitzner/beets-flask/HEAD/backend/tests/data/audio/Annix/Antidote/01 Antidote.mp3 -------------------------------------------------------------------------------- /backend/tests/data/audio/multi/Antidote/01 Antidote.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pSpitzner/beets-flask/HEAD/backend/tests/data/audio/multi/Antidote/01 Antidote.mp3 -------------------------------------------------------------------------------- /frontend/src/components/common/debugging/typing.ts: -------------------------------------------------------------------------------- 1 | export function assertUnreachable(_x: never): never { 2 | throw new Error("Didn't expect to get here"); 3 | } 4 | -------------------------------------------------------------------------------- /backend/beets_flask/importer/__init__.py: -------------------------------------------------------------------------------- 1 | from . import types 2 | from .states import SessionState 3 | 4 | __all__ = [ 5 | "SessionState", 6 | "types", 7 | ] 8 | -------------------------------------------------------------------------------- /backend/tests/data/audio/1991/Chant [SINGLE]/01 Chant.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pSpitzner/beets-flask/HEAD/backend/tests/data/audio/1991/Chant [SINGLE]/01 Chant.mp3 -------------------------------------------------------------------------------- /backend/tests/data/audio/multi/Chant [SINGLE]/01 Chant.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pSpitzner/beets-flask/HEAD/backend/tests/data/audio/multi/Chant [SINGLE]/01 Chant.mp3 -------------------------------------------------------------------------------- /backend/beets_flask/invoker/__init__.py: -------------------------------------------------------------------------------- 1 | from .enqueue import EnqueueKind, enqueue, enqueue_delete_items 2 | 3 | __all__ = [ 4 | "enqueue", 5 | "enqueue_delete_items", 6 | "EnqueueKind", 7 | ] 8 | -------------------------------------------------------------------------------- /frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | declare const __FRONTEND_VERSION__: string; 5 | declare const __MODE__: string; 6 | -------------------------------------------------------------------------------- /backend/beets_flask/database/__init__.py: -------------------------------------------------------------------------------- 1 | from .setup import db_session_factory, setup_database, with_db_session 2 | 3 | __all__ = [ 4 | "setup_database", 5 | "db_session_factory", 6 | "with_db_session", 7 | ] 8 | -------------------------------------------------------------------------------- /frontend/src/components/common/link.tsx: -------------------------------------------------------------------------------- 1 | import { Link as MuiLink } from '@mui/material'; 2 | import { createLink } from '@tanstack/react-router'; 3 | 4 | /** Custom link component allows for mui styling but tanstack safety */ 5 | export const Link = createLink(MuiLink); 6 | -------------------------------------------------------------------------------- /frontend/prettier.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @see https://prettier.io/docs/configuration 3 | * @type {import("prettier").Config} 4 | */ 5 | const config = { 6 | trailingComma: "es5", 7 | tabWidth: 4, 8 | semi: true, 9 | singleQuote: true, 10 | }; 11 | 12 | export default config; -------------------------------------------------------------------------------- /frontend/src/components/common/debugging/json.tsx: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 2 | export function JSONPretty(props: any) { 3 | return ( 4 |
5 |             {JSON.stringify(props, undefined, 4)}
6 |         
7 | ); 8 | } 9 | -------------------------------------------------------------------------------- /backend/beets_flask/database/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import Base 2 | from .states import CandidateStateInDb, FolderInDb, SessionStateInDb, TaskStateInDb 3 | 4 | __all__ = [ 5 | "Base", 6 | "FolderInDb", 7 | "SessionStateInDb", 8 | "TaskStateInDb", 9 | "CandidateStateInDb", 10 | ] 11 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /frontend/src/components/common/table/index.tsx: -------------------------------------------------------------------------------- 1 | export { 2 | DynamicFlowGrid, 3 | type CellComponentProps, 4 | type DynamicFlowGridProps, 5 | } from './Grid'; 6 | export { ViewToggle, type ViewToggleProps } from './ViewToggle'; 7 | export { 8 | SortToggle, 9 | type SortToggleProps, 10 | type SortItem, 11 | type CurrentSort, 12 | } from './Sort'; 13 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | # Ruff version. 4 | rev: v0.11.2 5 | hooks: 6 | # Run the linter. 7 | - id: ruff 8 | args: [--fix, --config=backend/pyproject.toml] 9 | # Run the formatter. 10 | - id: ruff-format 11 | args: [--config=backend/pyproject.toml] 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: feature_request 6 | assignees: '' 7 | 8 | --- 9 | 10 | Just some ideas how to get started with the feature request: 11 | 12 | - Is your feature request related to a problem? Please describe. 13 | - Describe the solution you'd like 14 | - Describe alternatives you've considered 15 | - Additional context 16 | -------------------------------------------------------------------------------- /frontend/src/components/common/units/bytes.ts: -------------------------------------------------------------------------------- 1 | export function humanizeBytes(bytes?: number): string { 2 | if (bytes === undefined) return 'unknown'; 3 | 4 | const units = ['bytes', 'kb', 'mb', 'gb', 'tb']; 5 | if (bytes === 0) return '0 bytes'; 6 | 7 | const i = Math.floor(Math.log(bytes) / Math.log(1024)); 8 | const value = bytes / Math.pow(1024, i); 9 | 10 | return `${parseFloat(value.toFixed(1))} ${units[i]}`; 11 | } 12 | -------------------------------------------------------------------------------- /backend/beets_flask/config/__init__.py: -------------------------------------------------------------------------------- 1 | from .beets_config import get_config 2 | from .flask_config import ( 3 | DeploymentDocker, 4 | DevelopmentDocker, 5 | DevelopmentLocal, 6 | ServerConfig, 7 | Testing, 8 | get_flask_config, 9 | ) 10 | 11 | __all__ = [ 12 | "get_config", 13 | "get_flask_config", 14 | "ServerConfig", 15 | "Testing", 16 | "DevelopmentLocal", 17 | "DevelopmentDocker", 18 | "DeploymentDocker", 19 | ] 20 | -------------------------------------------------------------------------------- /docs/develop/resources/index.md: -------------------------------------------------------------------------------- 1 | # Developer resources 2 | 3 | This document is meant as a primer for developers who want to get started with our codebase. Once you feel comfortable with the code, you may want to check out our [contribution guide](../contribution.md). 4 | 5 | ```{admonition} Note 6 | Feel free to open an issue or a pull request if you have any suggestions or questions. 7 | ``` 8 | 9 | ```{toctree} 10 | docker.md 11 | documentation.md 12 | frontend.md 13 | backend.md 14 | macos.md 15 | ``` 16 | -------------------------------------------------------------------------------- /docker/docker-compose.tests.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | beets-flask-tests: 3 | container_name: beets-flask-tests 4 | hostname: beets-container 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | target: test 9 | image: beets-flask-tests 10 | ports: 11 | - "5001:5001" 12 | - "5173:5173" 13 | environment: 14 | # 502 is default on macos, 1000 on linux 15 | USER_ID: 502 16 | GROUP_ID: 502 17 | volumes: 18 | - ./:/repo/ 19 | -------------------------------------------------------------------------------- /frontend/src/components/common/strings.tsx: -------------------------------------------------------------------------------- 1 | export function capitalizeFirstLetter(string: string) { 2 | return string.charAt(0).toUpperCase() + string.slice(1); 3 | } 4 | 5 | export function toHex(str: string) { 6 | return Array.from(new TextEncoder().encode(str)) 7 | .map((b) => b.toString(16).padStart(2, '0')) 8 | .join(''); 9 | } 10 | 11 | export function fromHex(hex: string) { 12 | const bytes = new Uint8Array( 13 | hex.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)) 14 | ); 15 | return new TextDecoder().decode(bytes); 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/routes/terminal/index.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from '@tanstack/react-router'; 2 | 3 | import { PageWrapper } from '@/components/common/page'; 4 | import { Terminal } from '@/components/frontpage/terminal'; 5 | 6 | export const Route = createFileRoute('/terminal/')({ 7 | component: TerminalPage, 8 | }); 9 | 10 | function TerminalPage() { 11 | return ( 12 | 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /backend/tests/integration/test_routes/test_config.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.asyncio 5 | async def test_get_all(client): 6 | # Not really much we can test here 7 | response = await client.get("/api_v1/config/all") 8 | 9 | assert response.status_code == 200 10 | 11 | 12 | @pytest.mark.asyncio 13 | async def test_get_basic(client): 14 | response = await client.get("/api_v1/config/") 15 | 16 | assert response.status_code == 200 17 | 18 | data = await response.get_json() 19 | 20 | assert "gui" in data 21 | assert "import" in data 22 | assert "match" in data 23 | -------------------------------------------------------------------------------- /frontend/src/api/dbfolder.ts: -------------------------------------------------------------------------------- 1 | interface DBFolder { 2 | full_path: string; 3 | is_album: boolean | null; 4 | id: string; 5 | created_at: Date; 6 | updated_at: Date; 7 | } 8 | 9 | export const folderByTaskId = (taskId: string) => ({ 10 | queryKey: ['dbfolder', taskId], 11 | queryFn: async () => { 12 | const response = await fetch(`/dbfolder/by_task/${taskId}`); 13 | const res = (await response.json()) as DBFolder; 14 | res.created_at = new Date(res.created_at); 15 | res.updated_at = new Date(res.updated_at); 16 | return res; 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /docs/_templates/base.html: -------------------------------------------------------------------------------- 1 | {% extends "furo/base.html" %} 2 | 3 | {%- block scripts %} 4 | 5 | {# Custom JS #} 6 | {%- block regular_scripts -%} 7 | {% for path in script_files -%} 8 | {{ js_tag(path) }} 9 | {% endfor -%} 10 | {%- endblock regular_scripts -%} 11 | 12 | {# Theme-related JavaScript code #} 13 | {%- block theme_scripts -%} 14 | {%- endblock -%} 15 | 16 | 17 | 18 | 27 | 28 | {% endblock %} -------------------------------------------------------------------------------- /backend/tests/unit/test_setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from beets_flask.logger import log 4 | 5 | 6 | def test_log(): 7 | """Test that logger is correctly set up for testing.""" 8 | 9 | assert "PYTEST_CURRENT_TEST" in os.environ 10 | 11 | # Logger should have no handlers 12 | assert not log.handlers 13 | assert log.level == 10 14 | assert log.name == "beets-flask" 15 | 16 | 17 | def test_config(): 18 | """Test that config is correctly set up for testing.""" 19 | import tempfile 20 | 21 | dir = os.environ.get("BEETSFLASKDIR") 22 | assert dir is not None 23 | assert str(tempfile.tempdir) in dir 24 | -------------------------------------------------------------------------------- /backend/launch_db_init.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | # dirty workaround, we pretend this is a rq worker so we get the logger to create 4 | # a child log with pid 5 | os.environ.setdefault("RQ_JOB_ID", "dbin") 6 | 7 | from beets.ui import _open_library 8 | 9 | from beets_flask.config.beets_config import get_config 10 | from beets_flask.database import setup_database 11 | from beets_flask.logger import log 12 | 13 | if __name__ == "__main__": 14 | log.debug("Launching database init worker") 15 | 16 | # ensue beets own db is created 17 | config = get_config() 18 | _open_library(config) 19 | 20 | # ensure beets-flask db is created 21 | setup_database() 22 | -------------------------------------------------------------------------------- /docs/develop/resources/documentation.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | For documentation we use [sphinx](https://www.sphinx-doc.org/en/master/) and [MyST](https://myst-parser.readthedocs.io/en/latest/). This allows us to write markdown files and include them in the documentation. You can find all documentation files in the `docs` folder. 4 | 5 | You may build the documentation locally with. 6 | 7 | ```bash 8 | # Install the requirements 9 | cd backend 10 | pip install -e .[docs] 11 | # Build the documentation 12 | cd ../docs 13 | make html 14 | ``` 15 | 16 | This will create a `docs/build/html` folder with the documentation. You can open the `index.html` file in any browser to view the documentation. -------------------------------------------------------------------------------- /frontend/src/routes/library/(resources)/item.$itemId.identifier.tsx: -------------------------------------------------------------------------------- 1 | import { useSuspenseQuery } from '@tanstack/react-query'; 2 | import { createFileRoute } from '@tanstack/react-router'; 3 | 4 | import { itemQueryOptions } from '@/api/library'; 5 | import { Identifier } from '@/components/library/itemold'; 6 | 7 | export const Route = createFileRoute( 8 | '/library/(resources)/item/$itemId/identifier' 9 | )({ 10 | component: RouteComponent, 11 | }); 12 | 13 | function RouteComponent() { 14 | const params = Route.useParams(); 15 | const { data: item } = useSuspenseQuery( 16 | itemQueryOptions(params.itemId, false) 17 | ); 18 | 19 | return ; 20 | } 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Expected behavior** 14 | A clear and concise description of what you expected to happen. 15 | 16 | **To Reproduce** 17 | 18 | - Go to '...' 19 | - Click on '....' 20 | - Scroll down to '....' 21 | - See error 22 | - Technical Details 23 | - Things that might help us isolate the issue: 24 | 25 | **Technical Details** 26 | - Screenshots 27 | - Version number of beets-flask 28 | - Log outputs (frontend and backend) 29 | - Configs 30 | - Docker-compose file 31 | -------------------------------------------------------------------------------- /frontend/src/components/common/page.tsx: -------------------------------------------------------------------------------- 1 | /** Common page components */ 2 | 3 | import { Box, styled } from '@mui/material'; 4 | 5 | /** Common wrapper for typical a typical page layout 6 | * adds a bit of css styling to allow for 7 | * dynamic break points on mobile. 8 | * 9 | * A column with some spacing on the left and right. 10 | * 11 | */ 12 | export const PageWrapper = styled(Box)(({ theme }) => ({ 13 | //On mobile devices, the page is full width 14 | width: '100%', 15 | maxWidth: '100%', 16 | [theme.breakpoints.up('laptop')]: { 17 | minWidth: theme.breakpoints.values.laptop, 18 | maxWidth: theme.breakpoints.values.desktop, 19 | }, 20 | // centered 21 | margin: '0 auto', 22 | })); 23 | -------------------------------------------------------------------------------- /docs/_templates/class.rst: -------------------------------------------------------------------------------- 1 | {{ fullname | escape | underline}} 2 | 3 | .. currentmodule:: {{ module }} 4 | 5 | .. autoclass:: {{ objname }} 6 | :members: 7 | :inherited-members: 8 | :special-members: __init__ 9 | 10 | {% block attributes %} 11 | {% if attributes %} 12 | .. rubric:: {{ _('Attributes') }} 13 | 14 | .. autosummary:: 15 | {% for item in attributes %} 16 | ~{{ name }}.{{ item }} 17 | {%- endfor %} 18 | {% endif %} 19 | {% endblock %} 20 | 21 | 22 | {% block methods %} 23 | 24 | {% if methods %} 25 | .. rubric:: {{ _('Methods') }} 26 | 27 | .. autosummary:: 28 | {% for item in methods %} 29 | ~{{ name }}.{{ item }} 30 | {%- endfor %} 31 | {% endif %} 32 | {% endblock %} 33 | 34 | 35 | -------------------------------------------------------------------------------- /docs/develop/resources/docker.md: -------------------------------------------------------------------------------- 1 | # Docker 2 | 3 | We use docker for containerization and deployment of our application. You can find the files needed to build the docker images in the [`docker`](https://github.com/pSpitzner/beets-flask/tree/main/docker) folder. 4 | 5 | Redis-Caching seems to be very persistent and we have not figured out how to completely reset it without _rebuilding_ the container. 6 | Thus, currently, after code changes that run inside a redis worker `docker-compose up --build` is needed even when live-mounting the repo. 7 | 8 | ## Entrypoints 9 | 10 | We use different entrypoints for the different environments. You can find all scripts in the [`docker/entrypoints`](https://github.com/pSpitzner/beets-flask/tree/main/docker/entrypoints) folder. 11 | -------------------------------------------------------------------------------- /backend/launch_watchdog_worker.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | 4 | # dirty workaround, we pretend this is a rq worker so we get the logger to create 5 | # a child log with pid 6 | os.environ.setdefault("RQ_JOB_ID", "wdog") 7 | 8 | from beets_flask.config import get_config 9 | from beets_flask.logger import log 10 | from beets_flask.watchdog.inbox import register_inboxes 11 | 12 | 13 | async def main(): 14 | log.debug(f"Launching inbox watchdog worker") 15 | config = get_config() 16 | debounce = int( 17 | config["gui"]["inbox"]["debounce_before_autotag"].as_number() # type: ignore 18 | ) 19 | watchdog = register_inboxes(debounce=debounce) 20 | 21 | 22 | if __name__ == "__main__": 23 | asyncio.run(main()) 24 | asyncio.get_event_loop().run_forever() 25 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | 22 | 23 | clean: 24 | rm -rf $(BUILDDIR)/* 25 | rm -rf $(SOURCEDIR)/_autosummary/* -------------------------------------------------------------------------------- /docker/entrypoints/entrypoint_user_scripts.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # check for user startup scripts 4 | if [ -f /config/startup.sh ]; then 5 | echo "Running user startup script from /config/startup.sh" 6 | /config/startup.sh 7 | fi 8 | if [ -f /config/beets-flask/startup.sh ]; then 9 | echo "Running user startup script from /config/beets-flask/startup.sh" 10 | /config/beets-flask/startup.sh 11 | fi 12 | 13 | 14 | # check for requirements.txt 15 | if [ -f /config/requirements.txt ]; then 16 | echo "Installing pip requirements from /config/requirements.txt" 17 | pip install -r /config/requirements.txt 18 | fi 19 | if [ -f /config/beets-flask/requirements.txt ]; then 20 | echo "Installing pip requirements from /config/beets-flask/requirements.txt" 21 | pip install -r /config/beets-flask/requirements.txt 22 | fi 23 | -------------------------------------------------------------------------------- /docs/develop/resources/macos.md: -------------------------------------------------------------------------------- 1 | # macOS 2 | 3 | Developing on a macOS host might have some quirks. 4 | 5 | - Quart-reloading works by monitoring for file changes, which might be broken depending on docker-desktops volume-mount driver. 6 | - pnpm packages need to be installed from within the container. Installing them natively on the host-side might get you versions that dont work in the container. `docker exec -it -u beetle beets-flask-dev bash` 7 | - but pytest, ruff and mypy should works directly on the host :) 8 | 9 | ## iTerm tmux 10 | 11 | You can use iTerm's tmux support to natively connect to the session that we have running in the beets container. Simply create a new iterm profile with the following start command: 12 | 13 | ``` 14 | ssh -t your_server "/usr/bin/docker exec -it -u beetle beets-flask /usr/bin/tmux -CC new -A -s beets-socket-term" 15 | ``` 16 | -------------------------------------------------------------------------------- /frontend/src/components/common/debugging/events.ts: -------------------------------------------------------------------------------- 1 | /** Allows to add a function to trigger on any event of an 2 | * given target. 3 | * * @param target - The target object to listen to events on. 4 | * * @param listener - The function to call when an event is triggered. 5 | * 6 | * Example usage: 7 | * ```javascript 8 | * import { addAllEvent } from './events'; 9 | * * const myElement = document.getElementById('myElement'); 10 | * * addAllEvent(myElement, (event) => { 11 | * * console.log(`Event ${event.type} triggered on myElement`); 12 | * * }); 13 | * ``` 14 | */ 15 | export function addAllEvent(target: EventTarget, listener: EventListener) { 16 | for (const key in target) { 17 | if (/^on/.test(key)) { 18 | const eventType = key.substr(2); 19 | target.addEventListener(eventType, listener); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /backend/beets_flask/config/config_bf_default.yaml: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------------ # 2 | # DO NOT EDIT THIS FILE # 3 | # ------------------------------------------------------------------------------------ # 4 | # these are the defaults for the gui. 5 | # you must provide your own config and map it to /config/beets-flask/config.yaml 6 | # to get started, see the auto-generated examples in /config/beets-flask/ 7 | 8 | gui: 9 | num_preview_workers: 4 10 | 11 | library: 12 | readonly: no 13 | artist_separators: [",", ";", "&"] 14 | 15 | terminal: 16 | start_path: "/repo" 17 | 18 | inbox: 19 | ignore: "_use_beets_ignore" 20 | debounce_before_autotag: 30 21 | concat_nested_folders: yes 22 | expand_files: no 23 | -------------------------------------------------------------------------------- /backend/tests/integration/test_websocket/test_errors.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | from socketio import AsyncClient 5 | 6 | log = logging.getLogger(__name__) 7 | 8 | 9 | @pytest.mark.asyncio 10 | async def test_ws_client(ws_client): 11 | assert isinstance(ws_client, AsyncClient) 12 | assert ws_client.sid is not None 13 | assert ws_client.connected is True 14 | 15 | 16 | @pytest.mark.asyncio 17 | async def test_generic_exc(ws_client: AsyncClient): 18 | # TODO: this needs some more thoughts, we have a more generalized 19 | # error handling now, we might want to adjust this for websocket 20 | r = await ws_client.call( 21 | "test_generic_exc", 22 | namespace="/test", 23 | timeout=5, 24 | ) 25 | 26 | assert r is not None 27 | assert isinstance(r, dict) 28 | assert r["error"] == "Exception" 29 | assert r["message"] == "Exception message" 30 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # following this reference suggesting to exclude everything, and only 2 | # manually include whats needed 3 | # https://markbirbeck.com/2018/12/07/getting-control-of-your-dockerignore-files/ 4 | 5 | # Let's taylor the docker ignore only for the production image, there is no real 6 | # need to push the dev image to the registry 7 | 8 | * 9 | 10 | !backend/beets_flask 11 | !backend/pyproject.toml 12 | !backend/main.py 13 | !backend/launch_*.py 14 | !backend/generate_types.py 15 | 16 | !configs/ 17 | 18 | !frontend/src/ 19 | !frontend/public/ 20 | !frontend/dist/ 21 | 22 | !frontend/.eslintrc.js 23 | !frontend/index.html 24 | !frontend/package.json 25 | !frontend/pnpm-lock.yaml 26 | !frontend/postcss.config.js 27 | !frontend/tailwind.config.js 28 | !frontend/tsconfig.json 29 | !frontend/vite.config.ts 30 | 31 | !README.md 32 | !LICENSE 33 | !docker/entrypoints/entrypoint*.sh 34 | !docker/entrypoints/common.sh 35 | -------------------------------------------------------------------------------- /frontend/src/routes/library/(resources)/album.$albumId.index.tsx: -------------------------------------------------------------------------------- 1 | import { useSuspenseQuery } from '@tanstack/react-query'; 2 | import { createFileRoute } from '@tanstack/react-router'; 3 | 4 | import { albumQueryOptions } from '@/api/library'; 5 | import { Tracklist } from '@/components/library/album'; 6 | 7 | export const Route = createFileRoute('/library/(resources)/album/$albumId/')({ 8 | component: RouteComponent, 9 | }); 10 | 11 | /** The default route shows the tracklist of an album. 12 | * 13 | * See ./route.tsx for the navigation and album header 14 | */ 15 | function RouteComponent() { 16 | const params = Route.useParams(); 17 | const { data: album } = useSuspenseQuery( 18 | albumQueryOptions(params.albumId, true, false) 19 | ); 20 | 21 | return ( 22 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /backend/beets_flask/invoker/job.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, NotRequired, TypedDict 4 | 5 | if TYPE_CHECKING: 6 | from rq.job import Job 7 | 8 | from .enqueue import EnqueueKind 9 | 10 | 11 | class ExtraJobMeta(TypedDict): 12 | job_frontend_ref: NotRequired[str | None] 13 | 14 | 15 | class RequiredJobMeta(TypedDict): 16 | folder_hash: str 17 | folder_path: str 18 | job_id: str 19 | job_kind: str # PS: EnqueueKind not json serializable 20 | 21 | 22 | class JobMeta(RequiredJobMeta, ExtraJobMeta): 23 | pass 24 | 25 | 26 | def _set_job_meta( 27 | job: Job, hash: str, path: str, kind: EnqueueKind, extra: ExtraJobMeta 28 | ): 29 | job.meta["folder_hash"] = hash 30 | job.meta["folder_path"] = path 31 | job.meta["job_id"] = job.id 32 | job.meta["job_kind"] = kind.value 33 | job.meta["job_frontend_ref"] = extra.get("job_frontend_ref", None) 34 | job.save_meta() 35 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide-toc: true 3 | --- 4 | 5 | ```{include} ../README.md 6 | :start-after: 7 | :end-before: 8 | ``` 9 | 10 | ```{note} 11 | This documentation is very much a work in progress. If you have any questions or suggestions, please feel free to open an issue or a pull request. 12 | ``` 13 | 14 | ## Features 15 | 16 | ```{include} ../README.md 17 | :start-after: 18 | :end-before: 19 | ``` 20 | 21 | ## Motivation 22 | 23 | ```{include} ../README.md 24 | :start-after: 25 | :end-before: 26 | ``` 27 | 28 | ```{toctree} 29 | :hidden: 30 | :caption: 📚 Guides 31 | 32 | getting-started.md 33 | configuration.md 34 | plugins.md 35 | limitations.md 36 | faq.md 37 | ``` 38 | 39 | ```{toctree} 40 | :hidden: 41 | :caption: 📖 Reference 42 | 43 | changelog.md 44 | develop/contribution.md 45 | develop/resources/index.md 46 | ``` 47 | -------------------------------------------------------------------------------- /docker/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | beets-flask: 3 | image: pspitzner/beets-flask:stable 4 | restart: unless-stopped 5 | ports: 6 | - "5001:5001" 7 | environment: 8 | # Change to your timezone 9 | TZ: "Europe/Berlin" 10 | # 502 is default on macos, 1000 on linux 11 | USER_ID: 1000 12 | GROUP_ID: 1000 13 | # Optional: Add extra groups to the beetle user for file permissions 14 | # Format: "group_name1:gid1,group_name2:gid2" 15 | # Example: EXTRA_GROUPS: "nas_shares:1001,media:1002" 16 | volumes: 17 | - /wherever/config/:/config 18 | # for music folders, match paths inside and out of container! 19 | - /music_path/inbox/:/music_path/inbox/ 20 | - /music_path/clean/:/music_path/clean/ 21 | # If you want to persist the logs, you can mount a logs directory 22 | # - /wherever/logs/:/logs 23 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": [ 7 | "es2024", 8 | "dom", 9 | "dom.Iterable" 10 | ], 11 | "module": "ES2022", 12 | "skipLibCheck": true, 13 | /* Bundler mode */ 14 | "moduleResolution": "bundler", 15 | "allowImportingTsExtensions": true, 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "noEmit": true, 19 | "jsx": "react-jsx", 20 | "strictNullChecks": true, // Recommended for better type inference 21 | /* Linting */ 22 | "strict": true, 23 | "baseUrl": ".", 24 | "paths": { 25 | "@/*": [ 26 | "src/*" 27 | ] 28 | }, 29 | //"diagnostics": true 30 | }, 31 | "include": [ 32 | "src" 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /frontend/src/api/monitor.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | import { JobMeta } from '@/pythonTypes'; 4 | 5 | export const queuesQueryOptions = { 6 | queryKey: ['monitor', 'queues'], 7 | queryFn: async () => { 8 | const response = await fetch('/monitor/queues'); 9 | return response.json() as Promise<{ queues: Record }>; 10 | }, 11 | }; 12 | 13 | export const workersQueryOptions = { 14 | queryKey: ['monitor', 'workers'], 15 | queryFn: async () => { 16 | const response = await fetch('/monitor/workers'); 17 | return response.json() as Promise<{ workers: Record }>; 18 | }, 19 | }; 20 | 21 | export const jobsQueryOptions = { 22 | queryKey: ['monitor', 'jobs'], 23 | queryFn: async () => { 24 | const response = await fetch('/monitor/jobs'); 25 | return response.json() as Promise< 26 | Array<{ q_name: string; job_id: string; meta: JobMeta }> 27 | >; 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /backend/beets_flask/server/routes/db_models/__init__.py: -------------------------------------------------------------------------------- 1 | from quart import Blueprint, Quart 2 | 3 | from beets_flask.database.models.states import CandidateStateInDb, TaskStateInDb 4 | 5 | from .base import ModelAPIBlueprint 6 | from .folder import FolderAPIBlueprint 7 | from .session import SessionAPIBlueprint 8 | 9 | 10 | def register_state_models(app: Blueprint | Quart): 11 | # Session is a special case and implements some more logic 12 | app.register_blueprint(SessionAPIBlueprint().blueprint) 13 | app.register_blueprint(FolderAPIBlueprint().blueprint) 14 | 15 | # It is not really used in the frontend but for future 16 | # reference we might want to use it 17 | app.register_blueprint( 18 | ModelAPIBlueprint( 19 | TaskStateInDb, 20 | url_prefix="/task", 21 | ).blueprint 22 | ) 23 | app.register_blueprint( 24 | ModelAPIBlueprint( 25 | CandidateStateInDb, 26 | url_prefix="/candidate", 27 | ).blueprint 28 | ) 29 | -------------------------------------------------------------------------------- /backend/launch_redis_workers.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from beets_flask.config.beets_config import get_config 4 | from beets_flask.logger import log 5 | 6 | num_preview_workers: int = 1 # Default value 7 | try: 8 | num_preview_workers = get_config()["gui"]["num_preview_workers"].get(int) # type: ignore 9 | log.debug(f"Got num_preview_workers from config: {num_preview_workers}") 10 | except: 11 | pass 12 | 13 | log.info(f"Starting {num_preview_workers} redis workers for preview generation") 14 | for i in range(num_preview_workers): 15 | os.system(f'rq worker preview --log-format "Preview worker $i: %(message)s" &') 16 | 17 | 18 | # imports are relatively fast, because they use previously fetched previews. 19 | # one worker should be enough, and this avoids problems from simultaneous db writes etc. 20 | num_import_workers = 1 21 | log.info(f"Starting {num_import_workers} redis workers for import") 22 | for i in range(num_import_workers): 23 | os.system(f'rq worker import --log-format "Import worker $i: %(message)s" &') 24 | -------------------------------------------------------------------------------- /frontend/src/routes/debug/error.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { createFileRoute } from '@tanstack/react-router'; 3 | 4 | import { PageWrapper } from '@/components/common/page'; 5 | 6 | export const Route = createFileRoute('/debug/error')({ 7 | component: RouteComponent, 8 | }); 9 | 10 | function RouteComponent() { 11 | const [throwError, setThrowError] = useState(false); 12 | 13 | if (throwError) { 14 | // This is a test error to check the default error component! 15 | throw new Error( 16 | 'This is a test error to check the default error component!' 17 | ); 18 | } 19 | 20 | return ( 21 | 22 |

Debug Error Page

23 |

24 | This page is used to test the error handling of the application. 25 | It will throw an error when the button is clicked. 26 |

27 | 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /docs/.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the OS, Python version and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.11" 13 | # You can also specify other tool versions: 14 | # nodejs: "19" 15 | # rust: "1.64" 16 | # golang: "1.19" 17 | 18 | # Build documentation in the "docs/" directory with Sphinx 19 | sphinx: 20 | configuration: ./docs/conf.py 21 | # Optionally build your docs in additional formats such as PDF and ePub 22 | # formats: 23 | # - pdf 24 | # - epub 25 | 26 | # Optional but recommended, declare the Python requirements required 27 | # to build your documentation 28 | # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 29 | python: 30 | install: 31 | - method: pip 32 | path: ./backend 33 | extra_requirements: 34 | - docs 35 | -------------------------------------------------------------------------------- /backend/tests/mixins/plugins.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from unittest import mock 3 | 4 | import pytest 5 | from beets.plugins import EventType, send 6 | 7 | 8 | class PluginEventsMixin(ABC): 9 | """ 10 | Allows to test events sent by plugins. 11 | This mixin captures events sent by plugins during tests. 12 | 13 | Usage: 14 | ``` 15 | class TestMyPlugin(PluginEventsMixin): 16 | def test_event(self): 17 | self.send_event("my_event", data="test") 18 | assert "my_event" in self.events 19 | ``` 20 | 21 | """ 22 | 23 | events: list[str] = [] 24 | 25 | def send_event(self, event: EventType, **kwargs): 26 | self.events.append(event) 27 | return send(event, **kwargs) 28 | 29 | @pytest.fixture(autouse=True, scope="function") 30 | def mock_events(self): 31 | """Mock the emit_status decorator""" 32 | 33 | with mock.patch( 34 | "beets.plugins.send", 35 | self.send_event, 36 | ): 37 | yield 38 | 39 | self.events = [] 40 | -------------------------------------------------------------------------------- /docker/entrypoints/common.sh: -------------------------------------------------------------------------------- 1 | # A number of common functions used by entrypoints 2 | 3 | log() { 4 | echo -e "[Entrypoint] $1" 5 | } 6 | 7 | log_error() { 8 | echo -e "\033[0;31m[Entrypoint] $1\033[0m" 9 | } 10 | 11 | log_warning() { 12 | echo -e "\033[0;33m[Entrypoint] $1\033[0m" 13 | } 14 | 15 | log_current_user() { 16 | log "Running as '$(whoami)' with UID $(id -u) and GID $(id -g)" 17 | log "Current working directory: $(pwd)" 18 | } 19 | 20 | log_version_info() { 21 | log "Version info:" 22 | log " Backend: $BACKEND_VERSION" 23 | log " Frontend: $FRONTEND_VERSION" 24 | log " Mode: $IB_SERVER_CONFIG" 25 | } 26 | 27 | get_version_info() { 28 | if [ -f /version/backend.txt ]; then 29 | export BACKEND_VERSION=$(cat /version/backend.txt) 30 | else 31 | export BACKEND_VERSION="unk" 32 | fi 33 | 34 | if [ -f /version/frontend.txt ]; then 35 | export FRONTEND_VERSION=$(cat /version/frontend.txt) 36 | else 37 | export FRONTEND_VERSION="unk" 38 | fi 39 | } 40 | 41 | # Populate the environment variables for the version info 42 | get_version_info 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2024 F. Paul Spitzner, Sebastian B. Mohr 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /backend/beets_flask/server/routes/__init__.py: -------------------------------------------------------------------------------- 1 | from quart import Blueprint, Quart 2 | 3 | from .art_preview import art_blueprint 4 | from .config import config_bp 5 | from .db_models import register_state_models 6 | from .exception import error_bp 7 | from .frontend import frontend_bp 8 | from .inbox import inbox_bp 9 | from .library import library_bp 10 | from .monitor import monitor_bp 11 | 12 | backend_bp = Blueprint("backend", __name__, url_prefix="/api_v1") 13 | 14 | # Register all backend blueprints 15 | backend_bp.register_blueprint(art_blueprint) 16 | backend_bp.register_blueprint(config_bp) 17 | backend_bp.register_blueprint(error_bp) 18 | backend_bp.register_blueprint(frontend_bp) 19 | backend_bp.register_blueprint(inbox_bp) 20 | backend_bp.register_blueprint(library_bp) 21 | backend_bp.register_blueprint(monitor_bp) 22 | 23 | 24 | def register_routes(app: Quart): 25 | # Register database state models 26 | # to api blueprint i.e. /api_v1/session, /api_v1/task & /api_v1/candidate 27 | register_state_models(backend_bp) 28 | 29 | app.register_blueprint(backend_bp) 30 | app.register_blueprint(frontend_bp) 31 | 32 | 33 | __all__ = ["register_routes"] 34 | -------------------------------------------------------------------------------- /backend/beets_flask/server/routes/frontend.py: -------------------------------------------------------------------------------- 1 | """The glue between the compiled vite frontend and our backend.""" 2 | 3 | from quart import Blueprint, current_app, send_from_directory 4 | 5 | frontend_bp = Blueprint("frontend", __name__) 6 | 7 | 8 | # Register frontend folder 9 | # basically a reverse proxy for the frontend 10 | @frontend_bp.route("/", defaults={"path": "index.html"}) 11 | @frontend_bp.route("/") 12 | async def reverse_proxy(path): 13 | """Link to vite resources.""" 14 | # not include assets 15 | if ( 16 | not "assets" in path 17 | and not "logo_beets.png" in path 18 | and not "logo_flask.png" in path 19 | and not path.startswith("favicon.ico") 20 | ): 21 | path = "index.html" 22 | 23 | # Remove everything infront of assets 24 | if "assets" in path: 25 | path = path[path.index("assets") :] 26 | if "logo_beets.png" in path: 27 | path = path[path.index("logo_beets.png") :] 28 | if "logo_flask.png" in path: 29 | path = path[path.index("logo_flask.png") :] 30 | 31 | r = await send_from_directory(current_app.config["FRONTEND_DIST_DIR"], path) 32 | return r 33 | -------------------------------------------------------------------------------- /backend/beets_flask/server/websocket/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from collections.abc import Callable 3 | from typing import cast 4 | 5 | import socketio 6 | 7 | old_on = socketio.AsyncServer.on 8 | 9 | 10 | # Gets rid of the type error in the decorator 11 | class TypedAsyncServer(socketio.AsyncServer): 12 | def on(self, event: str, namespace: str | None = None) -> Callable: ... # type: ignore 13 | 14 | 15 | if os.environ.get("PYTEST_CURRENT_TEST", ""): 16 | client_manager = None 17 | else: 18 | client_manager = socketio.AsyncRedisManager("redis://") 19 | 20 | sio: TypedAsyncServer = cast( 21 | TypedAsyncServer, 22 | socketio.AsyncServer( 23 | async_mode="asgi", 24 | logger=False, 25 | engineio_logger=False, 26 | cors_allowed_origins="*", 27 | client_manager=client_manager, 28 | ), 29 | ) 30 | 31 | 32 | def register_socketio(app): 33 | app.asgi_app = socketio.ASGIApp(sio, app.asgi_app, socketio_path="/socket.io") 34 | 35 | # Register all socketio namespaces 36 | from .status import register_status 37 | from .terminal import register_tmux 38 | 39 | register_tmux() 40 | register_status() 41 | -------------------------------------------------------------------------------- /docker/entrypoints/entrypoint_fix_permissions.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # this script runs both, in dev and in prod, so we have to check where we 4 | # can source common.sh from. 5 | if [ -f ./common.sh ]; then 6 | source ./common.sh 7 | elif [ -f ./docker/entrypoints/common.sh ]; then 8 | source ./docker/entrypoints/common.sh 9 | fi 10 | 11 | if [ ! -z "$USER_ID" ] && [ ! -z "$GROUP_ID" ]; then 12 | log "Fixing permissions as '$(whoami)' with UID $(id -u) and GID $(id -g)" 13 | log "Setting beetle user to $USER_ID:$GROUP_ID" 14 | groupmod -g $GROUP_ID beetle 15 | usermod -u $USER_ID -g $GROUP_ID beetle > /dev/null 2>&1 16 | log "User beetle now has $(id beetle)" 17 | find /home/beetle ! -user beetle -exec chown beetle:beetle {} + 18 | find /logs ! -user beetle -exec chown beetle:beetle {} + 19 | find /repo ! -user beetle -exec chown beetle:beetle {} + 20 | log "Done fixing permissions" 21 | else 22 | log "No USER_ID and GROUP_ID set, skipping permission updates" 23 | fi 24 | 25 | # add groups 26 | if [ -f ./entrypoint_add_groups.sh ]; then 27 | source ./entrypoint_add_groups.sh 28 | elif [ -f ./docker/entrypoints/entrypoint_add_groups.sh ]; then 29 | source ./docker/entrypoints/entrypoint_add_groups.sh 30 | fi 31 | -------------------------------------------------------------------------------- /docker/entrypoints/entrypoint.sh: -------------------------------------------------------------------------------- 1 | 2 | #!/bin/sh 3 | source ./common.sh 4 | 5 | log_current_user 6 | log_version_info 7 | 8 | cd /repo 9 | 10 | mkdir -p /repo/log 11 | mkdir -p /config/beets 12 | mkdir -p /config/beets-flask 13 | 14 | # ------------------------------------------------------------------------------------ # 15 | # start backend # 16 | # ------------------------------------------------------------------------------------ # 17 | 18 | # Ignore warnings for production builds 19 | export PYTHONWARNINGS="ignore" 20 | 21 | # running the server from inside the backend dir makes imports and redis easier 22 | cd /repo/backend 23 | 24 | redis-server --daemonize yes >/dev/null 2>&1 25 | 26 | 27 | # blocking 28 | python ./launch_db_init.py 29 | python ./launch_redis_workers.py > /logs/redis_workers.log 2>&1 30 | 31 | # keeps running in the background 32 | python ./launch_watchdog_worker.py & 33 | 34 | redis-cli FLUSHALL >/dev/null 2>&1 35 | 36 | # we need to run with one worker for socketio to work 37 | uvicorn beets_flask.server.app:create_app --port 5001 \ 38 | --host 0.0.0.0 \ 39 | --factory \ 40 | --workers 4 \ 41 | --use-colors \ 42 | --log-level info \ 43 | --no-access-log 44 | -------------------------------------------------------------------------------- /frontend/src/components/inbox/cards/common.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, Box, Typography } from '@mui/material'; 2 | 3 | export function CardHeader({ 4 | icon, 5 | title, 6 | subtitle, 7 | children, 8 | }: { 9 | icon: React.ReactNode; 10 | title: string; 11 | subtitle?: string; 12 | children?: React.ReactNode; 13 | }) { 14 | return ( 15 | 22 | 28 | {icon} 29 | 30 | 31 | 38 | {title} 39 | 40 | 41 | {subtitle} 42 | 43 | 44 | {children} 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /docs/develop/resources/backend.md: -------------------------------------------------------------------------------- 1 | # Backend 2 | 3 | Beets-Flask provides a quart application with REST API for the beets music library manager and a library for interacting with beets. 4 | 5 | ```{toctree} 6 | :hidden: 7 | 8 | ./state_serialize 9 | ``` 10 | 11 | ## Resumability of import 12 | 13 | By default beets has very limited support to resume an import after it has been triggered. For instance, once an import is canceled the next time the same folder is imported, beets will start from the beginning. This is not ideal for large imports, especially if you have a lot of plugins and candidate fetches may take a long time. 14 | 15 | To overcome this issue we added wrappers for the beets sessions and introduced an serializable session state. This allows us to save the state of the import and resume it later, e.g. in a database. To see an example of this, please check the [state serialization example](./state_serialize). 16 | 17 | ## Environment variables 18 | 19 | The configuration folders can be set via environment variables. This might be useful if you want to run the application in a different environment. The following values are our defaults for the production and dev docker containers: 20 | 21 | ``` 22 | BEETSDIR="/config/beets" 23 | BEETSFLASKDIR="/config/beets-flask" 24 | BEETSFLASKLOG="/logs/beets-flask.log" 25 | ``` 26 | 27 | 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/help-wanted.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Help wanted 3 | about: 'Please help: I have trouble setting this up.' 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Before posting an issue, please take a few minutes to search the docs and old issues. 11 | (Don't hesitate to ask though, no need to spend your whole evening 😁) 12 | 13 | Please include as much of the details below as you can: 14 | - What have you tried to get it working? 15 | - Where do you get stuck? 16 | - Maybe you already have a gut-feeling what might be the problem? 17 | 18 | --- 19 | 20 | **beets-flask config** 21 | ```yaml 22 | # paste config here 23 | ``` 24 | 25 | **beets config** 26 | ```yaml 27 | # paste config here, remove sensitive information like login data 28 | ``` 29 | 30 | **Docker-compose file / Docker command** 31 | ```yaml 32 | # paste here, in particular volume mounts 33 | ``` 34 | 35 | **Container logs** 36 | Find the output from `docker logs beets-flask` below: 37 | ``` 38 | > docker logs beets-flask 39 | [Entrypoint] Running as 'beetle' with UID 1000 and GID 1002 40 | [Entrypoint] Current working directory: /repo 41 | [Entrypoint] Version info: 42 | [Entrypoint] Backend: 1.1.1 43 | [Entrypoint] Frontend: 1.1.1 44 | [Entrypoint] Mode: prod 45 | INFO: Uvicorn running on http://0.0.0.0:5001 (Press CTRL+C to quit) 46 | ... 47 | ``` 48 | -------------------------------------------------------------------------------- /frontend/src/routes/inbox/task.$taskId.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute, redirect } from '@tanstack/react-router'; 2 | 3 | import { folderByTaskId } from '@/api/dbfolder'; 4 | import { LoadingWithFeedback } from '@/components/common/loading'; 5 | import { PageWrapper } from '@/components/common/page'; 6 | 7 | export const Route = createFileRoute('/inbox/task/$taskId')({ 8 | loader: async ({ context: { queryClient }, params }) => { 9 | // Redirect to the hash route 10 | const data = await queryClient.ensureQueryData( 11 | folderByTaskId(params.taskId) 12 | ); 13 | 14 | redirect({ 15 | to: `/inbox/folder/$path/$hash`, 16 | params: { 17 | hash: data.id, 18 | path: data.full_path, 19 | }, 20 | throw: true, 21 | }); 22 | }, 23 | pendingComponent: () => ( 24 | 33 | 37 | 38 | ), 39 | }); 40 | -------------------------------------------------------------------------------- /frontend/src/components/common/table/ViewToggle.tsx: -------------------------------------------------------------------------------- 1 | import { GridIcon, ListIcon } from 'lucide-react'; 2 | import { 3 | ToggleButton, 4 | ToggleButtonGroup, 5 | ToggleButtonGroupProps, 6 | useTheme, 7 | } from '@mui/material'; 8 | 9 | export interface ViewToggleProps extends ToggleButtonGroupProps { 10 | view: 'list' | 'grid'; 11 | setView: (view: 'list' | 'grid') => void; 12 | } 13 | 14 | /** 15 | * A toggle button group to switch between list and grid view. 16 | */ 17 | export function ViewToggle({ view, setView, ...props }: ViewToggleProps) { 18 | const theme = useTheme(); 19 | return ( 20 | , 24 | v: 'list' | 'grid' | null 25 | ) => { 26 | if (v) { 27 | setView(v); 28 | } 29 | }} 30 | exclusive 31 | color="primary" 32 | aria-label="View type" 33 | {...props} 34 | > 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /docs/develop/resources/frontend.md: -------------------------------------------------------------------------------- 1 | # Frontend 2 | 3 | The frontend is a website that is statically generated on build with the help of [Vite](https://vitejs.dev/). You can find all realted files in the `frontend` folder. 4 | 5 | 6 | We use the follow `Tech Stack`: 7 | 8 | - [React](https://react.dev/) 9 | - [Vite](https://vitejs.dev/) 10 | - [Tanstack router](https://tanstack.com/router/latest) 11 | - [MUI Core](https://mui.com/material-ui/all-components/) 12 | - [Lucide icons](https://lucide.dev/icons/) 13 | 14 | 15 | ## Package manager 16 | 17 | We use [pnpm](https://pnpm.io/) as package manager. If you have npm or yarn installed, you can install pnpm via corepack: 18 | ```bash 19 | corepack enable pnpm 20 | corepack use pnpm@latest 21 | ``` 22 | alternatively follow the isntallation guide [here](https://pnpm.io/installation). 23 | 24 | ## Scripts 25 | 26 | We expose some helper scripts in the package.json which you can run outside of the container. You can run them with `pnpm