├── 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