diff --git a/.dockerignore b/.dockerignore
index a2fff9fe..9a67d011 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,34 +1,34 @@
-# following this reference suggesting to exclude everything, and only
-# manually include whats needed
-# https://markbirbeck.com/2018/12/07/getting-control-of-your-dockerignore-files/
-
-# Let's taylor the docker ignore only for the production image, there is no real
-# need to push the dev image to the registry
-
-*
-
-!backend/beets_flask
-!backend/pyproject.toml
-!backend/main.py
-!backend/launch_*.py
-!backend/generate_types.py
-
-!configs/
-
-!frontend/src/
-!frontend/public/
-!frontend/dist/
-
-!frontend/.eslintrc.js
-!frontend/index.html
-!frontend/package.json
-!frontend/pnpm-lock.yaml
-!frontend/postcss.config.js
-!frontend/tailwind.config.js
-!frontend/tsconfig.json
-!frontend/vite.config.ts
-
-!README.md
-!LICENSE
-!docker/entrypoints/entrypoint*.sh
-!docker/entrypoints/common.sh
+# following this reference suggesting to exclude everything, and only
+# manually include whats needed
+# https://markbirbeck.com/2018/12/07/getting-control-of-your-dockerignore-files/
+
+# Let's taylor the docker ignore only for the production image, there is no real
+# need to push the dev image to the registry
+
+*
+
+!backend/beets_flask
+!backend/pyproject.toml
+!backend/main.py
+!backend/launch_*.py
+!backend/generate_types.py
+
+!configs/
+
+!frontend/src/
+!frontend/public/
+!frontend/dist/
+
+!frontend/.eslintrc.js
+!frontend/index.html
+!frontend/package.json
+!frontend/pnpm-lock.yaml
+!frontend/postcss.config.js
+!frontend/tailwind.config.js
+!frontend/tsconfig.json
+!frontend/vite.config.ts
+
+!README.md
+!LICENSE
+!docker/entrypoints/entrypoint*.sh
+!docker/entrypoints/common.sh
diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
index 48c5b53d..2d27976a 100644
--- a/.git-blame-ignore-revs
+++ b/.git-blame-ignore-revs
@@ -1,4 +1,4 @@
-# PyUpgrade
-d7fdc2e24eec7e5b58585ea2a82509ffd3283ad0
-# Prettier formatting
+# PyUpgrade
+d7fdc2e24eec7e5b58585ea2a82509ffd3283ad0
+# Prettier formatting
15d9ca8c5b430e61c11d0a59cf507bba56ccad4d
\ No newline at end of file
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..dfdb8b77
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+*.sh text eol=lf
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index 367289dc..f21376e5 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -1,30 +1,30 @@
----
-name: Bug report
-about: Create a report to help us improve
-title: ''
-labels: bug
-assignees: ''
-
----
-
-**Describe the bug**
-A clear and concise description of what the bug is.
-
-**Expected behavior**
-A clear and concise description of what you expected to happen.
-
-**To Reproduce**
-
-- Go to '...'
-- Click on '....'
-- Scroll down to '....'
-- See error
-- Technical Details
-- Things that might help us isolate the issue:
-
-**Technical Details**
-- Screenshots
-- Version number of beets-flask
-- Log outputs (frontend and backend)
-- Configs
-- Docker-compose file
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: bug
+assignees: ''
+
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**To Reproduce**
+
+- Go to '...'
+- Click on '....'
+- Scroll down to '....'
+- See error
+- Technical Details
+- Things that might help us isolate the issue:
+
+**Technical Details**
+- Screenshots
+- Version number of beets-flask
+- Log outputs (frontend and backend)
+- Configs
+- Docker-compose file
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
index 381846f6..06c068e9 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -1,15 +1,15 @@
----
-name: Feature request
-about: Suggest an idea for this project
-title: ''
-labels: feature_request
-assignees: ''
-
----
-
-Just some ideas how to get started with the feature request:
-
-- Is your feature request related to a problem? Please describe.
-- Describe the solution you'd like
-- Describe alternatives you've considered
-- Additional context
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: feature_request
+assignees: ''
+
+---
+
+Just some ideas how to get started with the feature request:
+
+- Is your feature request related to a problem? Please describe.
+- Describe the solution you'd like
+- Describe alternatives you've considered
+- Additional context
diff --git a/.github/ISSUE_TEMPLATE/help-wanted.md b/.github/ISSUE_TEMPLATE/help-wanted.md
index f77b80b3..6dcf54bb 100644
--- a/.github/ISSUE_TEMPLATE/help-wanted.md
+++ b/.github/ISSUE_TEMPLATE/help-wanted.md
@@ -1,47 +1,47 @@
----
-name: Help wanted
-about: 'Please help: I have trouble setting this up.'
-title: ''
-labels: ''
-assignees: ''
-
----
-
-Before posting an issue, please take a few minutes to search the docs and old issues.
-(Don't hesitate to ask though, no need to spend your whole evening 😁)
-
-Please include as much of the details below as you can:
-- What have you tried to get it working?
-- Where do you get stuck?
-- Maybe you already have a gut-feeling what might be the problem?
-
----
-
-**beets-flask config**
-```yaml
-# paste config here
-```
-
-**beets config**
-```yaml
-# paste config here, remove sensitive information like login data
-```
-
-**Docker-compose file / Docker command**
-```yaml
-# paste here, in particular volume mounts
-```
-
-**Container logs**
-Find the output from `docker logs beets-flask` below:
-```
-> docker logs beets-flask
-[Entrypoint] Running as 'beetle' with UID 1000 and GID 1002
-[Entrypoint] Current working directory: /repo
-[Entrypoint] Version info:
-[Entrypoint] Backend: 1.1.1
-[Entrypoint] Frontend: 1.1.1
-[Entrypoint] Mode: prod
-INFO: Uvicorn running on http://0.0.0.0:5001 (Press CTRL+C to quit)
-...
-```
+---
+name: Help wanted
+about: 'Please help: I have trouble setting this up.'
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+Before posting an issue, please take a few minutes to search the docs and old issues.
+(Don't hesitate to ask though, no need to spend your whole evening 😁)
+
+Please include as much of the details below as you can:
+- What have you tried to get it working?
+- Where do you get stuck?
+- Maybe you already have a gut-feeling what might be the problem?
+
+---
+
+**beets-flask config**
+```yaml
+# paste config here
+```
+
+**beets config**
+```yaml
+# paste config here, remove sensitive information like login data
+```
+
+**Docker-compose file / Docker command**
+```yaml
+# paste here, in particular volume mounts
+```
+
+**Container logs**
+Find the output from `docker logs beets-flask` below:
+```
+> docker logs beets-flask
+[Entrypoint] Running as 'beetle' with UID 1000 and GID 1002
+[Entrypoint] Current working directory: /repo
+[Entrypoint] Version info:
+[Entrypoint] Backend: 1.1.1
+[Entrypoint] Frontend: 1.1.1
+[Entrypoint] Mode: prod
+INFO: Uvicorn running on http://0.0.0.0:5001 (Press CTRL+C to quit)
+...
+```
diff --git a/.github/workflows/changelog_reminder.yml b/.github/workflows/changelog_reminder.yml
index dd5cb4f9..fa589af6 100644
--- a/.github/workflows/changelog_reminder.yml
+++ b/.github/workflows/changelog_reminder.yml
@@ -1,38 +1,38 @@
-name: Verify changelog updated
-
-on:
- pull_request_target:
- types:
- - opened
- - ready_for_review
-
-jobs:
- check_changes:
- runs-on: ubuntu-latest
- permissions:
- contents: read
- pull-requests: write
- steps:
- - uses: actions/checkout@v4
-
- - name: Get all updated Python files
- id: changed-files
- uses: tj-actions/changed-files@v46
- with:
- files: |
- **.py
- **.ts
- **.tsx
-
- - name: Check for the changelog update
- id: changelog-update
- uses: tj-actions/changed-files@v46
- with:
- files: CHANGELOG.md
-
- - name: Comment under the PR with a reminder
- if: steps.changed-files.outputs.any_changed == 'true' && steps.changelog-update.outputs.any_changed == 'false'
- uses: thollander/actions-comment-pull-request@v2
- with:
- message: "Thank you for the PR! The changelog has not been updated, so here is a friendly reminder to check if you need to add an entry."
- GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
+name: Verify changelog updated
+
+on:
+ pull_request_target:
+ types:
+ - opened
+ - ready_for_review
+
+jobs:
+ check_changes:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: write
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Get all updated Python files
+ id: changed-files
+ uses: tj-actions/changed-files@v46
+ with:
+ files: |
+ **.py
+ **.ts
+ **.tsx
+
+ - name: Check for the changelog update
+ id: changelog-update
+ uses: tj-actions/changed-files@v46
+ with:
+ files: CHANGELOG.md
+
+ - name: Comment under the PR with a reminder
+ if: steps.changed-files.outputs.any_changed == 'true' && steps.changelog-update.outputs.any_changed == 'false'
+ uses: thollander/actions-comment-pull-request@v2
+ with:
+ message: "Thank you for the PR! The changelog has not been updated, so here is a friendly reminder to check if you need to add an entry."
+ GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
diff --git a/.github/workflows/docker_hub.yml b/.github/workflows/docker_hub.yml
index b4dcfade..3c110f94 100644
--- a/.github/workflows/docker_hub.yml
+++ b/.github/workflows/docker_hub.yml
@@ -1,72 +1,72 @@
-name: docker-hub
-
-permissions:
- contents: read
- packages: write
-
-on:
- push:
- tags:
- - "v*.*.*"
- - "v*.*.*-*"
- - "test-*"
- workflow_dispatch:
-
-jobs:
- docker:
- runs-on: ubuntu-latest
- steps:
- - name: Docker meta
- id: meta
- uses: docker/metadata-action@v5
- # generate Docker tags based on the following events/attributes
- with:
- # list of Docker images to use as base name for tags
- images: |
- pspitzner/beets-flask
- ghcr.io/pspitzner/beets-flask
- # generate Docker tags based on the following events/attributes
- # We use semver and allow pre-releases (e.g. v1.0.0-b1 or v1.0.0-rc3) to be tagged as 'latest' for testing purposes
- # The 'stable' tag is only applied to non-prerelease versions (e.g. v1.0.0)
- tags: |
- type=ref,event=tag
- type=semver,pattern={{raw}}
- type=sha
- type=raw,value=rc,enable=${{ startsWith(github.ref, 'refs/tags/v') && contains(github.ref, '-rc') }}
- type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }}
- type=raw,value=latest,enable=true
-
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v3
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
-
- - name: Login to GitHub Container Registry
- uses: docker/login-action@v3
- with:
- registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Login to Docker Hub
- uses: docker/login-action@v3
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- - name: Build and push
- uses: docker/build-push-action@v6
- with:
- file: ./docker/Dockerfile
- platforms: linux/amd64,linux/arm64
- target: prod
- context: .
- push: ${{ github.event_name != 'pull_request' }}
- tags: ${{ steps.meta.outputs.tags }}
- labels: ${{ steps.meta.outputs.labels }}
- cache-from: type=gha
- cache-to: type=gha,mode=max
+name: docker-hub
+
+permissions:
+ contents: read
+ packages: write
+
+on:
+ push:
+ tags:
+ - "v*.*.*"
+ - "v*.*.*-*"
+ - "test-*"
+ workflow_dispatch:
+
+jobs:
+ docker:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Docker meta
+ id: meta
+ uses: docker/metadata-action@v5
+ # generate Docker tags based on the following events/attributes
+ with:
+ # list of Docker images to use as base name for tags
+ images: |
+ pspitzner/beets-flask
+ ghcr.io/pspitzner/beets-flask
+ # generate Docker tags based on the following events/attributes
+ # We use semver and allow pre-releases (e.g. v1.0.0-b1 or v1.0.0-rc3) to be tagged as 'latest' for testing purposes
+ # The 'stable' tag is only applied to non-prerelease versions (e.g. v1.0.0)
+ tags: |
+ type=ref,event=tag
+ type=semver,pattern={{raw}}
+ type=sha
+ type=raw,value=rc,enable=${{ startsWith(github.ref, 'refs/tags/v') && contains(github.ref, '-rc') }}
+ type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }}
+ type=raw,value=latest,enable=true
+
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Build and push
+ uses: docker/build-push-action@v6
+ with:
+ file: ./docker/Dockerfile
+ platforms: linux/amd64,linux/arm64
+ target: prod
+ context: .
+ push: ${{ github.event_name != 'pull_request' }}
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/.github/workflows/docker_personal.yml b/.github/workflows/docker_personal.yml
new file mode 100644
index 00000000..61a77afe
--- /dev/null
+++ b/.github/workflows/docker_personal.yml
@@ -0,0 +1,53 @@
+name: docker-personal
+
+permissions:
+ contents: read
+ packages: write
+
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+jobs:
+ docker:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Docker meta
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ghcr.io/matthieudecamy/beets-flask
+ tags: |
+ type=sha
+ type=raw,value=latest
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build and push
+ uses: docker/build-push-action@v6
+ with:
+ file: ./docker/Dockerfile
+ platforms: linux/amd64,linux/arm64
+ target: prod
+ context: .
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/.github/workflows/javascript.yml b/.github/workflows/javascript.yml
index d207df1a..accd9f3e 100644
--- a/.github/workflows/javascript.yml
+++ b/.github/workflows/javascript.yml
@@ -1,62 +1,62 @@
-name: Javascript checks
-
-on:
- push:
- branches: ["main"]
- paths:
- - frontend/**
- pull_request:
- # The branches below must be a subset of the branches above
- branches: ["main"]
- workflow_dispatch:
-
-jobs:
- eslint:
- name: Javascript checks
- runs-on: ubuntu-latest
- permissions:
- contents: read
- security-events: write
- actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status
- strategy:
- matrix:
- node-version: ["22.20.0"]
- steps:
- - name: Checkout code
- uses: actions/checkout@v4
- - name: Install pnpm
- uses: pnpm/action-setup@v4
- with:
- version: 9
- - name: Use Node.js ${{ matrix.node-version }}
- uses: actions/setup-node@v4
- with:
- node-version: ${{ matrix.node-version }}
- cache: "pnpm"
- cache-dependency-path: frontend/pnpm-lock.yaml
- - name: Install dependencies
- run: |
- cd ./frontend
- pnpm install --frozen-lockfile
- - name: Run ESLint
- id: eslint
- continue-on-error: true
- run: |
- cd ./frontend
- pnpm run lint
- - name: Run prettier
- id: prettier
- continue-on-error: true
- run: |
- cd ./frontend
- pnpm run format-check
- - name: Run TypeScript type check
- id: check-types
- run: |
- cd ./frontend
- pnpm run check-types
- - name: Check for failures
- if: steps.eslint.outcome == 'failure' || steps.check-types.outcome == 'failure' || steps.prettier.outcome == 'failure'
- run: |
- echo "One or more checks failed"
- exit 1
+name: Javascript checks
+
+on:
+ push:
+ branches: ["main"]
+ paths:
+ - frontend/**
+ pull_request:
+ # The branches below must be a subset of the branches above
+ branches: ["main"]
+ workflow_dispatch:
+
+jobs:
+ eslint:
+ name: Javascript checks
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ security-events: write
+ actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status
+ strategy:
+ matrix:
+ node-version: ["22.20.0"]
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ - name: Install pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 9
+ - name: Use Node.js ${{ matrix.node-version }}
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node-version }}
+ cache: "pnpm"
+ cache-dependency-path: frontend/pnpm-lock.yaml
+ - name: Install dependencies
+ run: |
+ cd ./frontend
+ pnpm install --frozen-lockfile
+ - name: Run ESLint
+ id: eslint
+ continue-on-error: true
+ run: |
+ cd ./frontend
+ pnpm run lint
+ - name: Run prettier
+ id: prettier
+ continue-on-error: true
+ run: |
+ cd ./frontend
+ pnpm run format-check
+ - name: Run TypeScript type check
+ id: check-types
+ run: |
+ cd ./frontend
+ pnpm run check-types
+ - name: Check for failures
+ if: steps.eslint.outcome == 'failure' || steps.check-types.outcome == 'failure' || steps.prettier.outcome == 'failure'
+ run: |
+ echo "One or more checks failed"
+ exit 1
diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml
index cdf8c0e8..3462a676 100644
--- a/.github/workflows/python.yml
+++ b/.github/workflows/python.yml
@@ -1,50 +1,50 @@
-name: Python checks
-
-on:
- push:
- branches: ["main"]
- paths:
- - backend/**
- pull_request:
- # The branches below must be a subset of the branches above
- branches: ["main"]
-
-jobs:
- python:
- name: Python checks
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - name: Install Python
- uses: actions/setup-python@v5
- with:
- python-version: "3.11"
- - name: Install dependencies
- run: |
- cd ./backend
- python -m pip install --upgrade pip
- pip install ruff
- pip install .[typed,test]
- - name: Check style with Ruff
- continue-on-error: true
- id: ruff
- run: |
- cd ./backend
- ruff check --output-format=github .
- - name: Check type hints with mypy
- continue-on-error: true
- id: mypy
- run: |
- cd ./backend
- mypy --show-error-codes --check-untyped-defs --config-file ./pyproject.toml .
- - name: Test with pytest
- env:
- PYTEST_ADDOPTS: "--color=yes"
- run: |
- cd ./backend
- coverage run -m pytest -v
- - name: Check for failures
- if: steps.ruff.outcome == 'failure' || steps.mypy.outcome == 'failure'
- run: |
- echo "One or more checks failed"
- exit 1
+name: Python checks
+
+on:
+ push:
+ branches: ["main"]
+ paths:
+ - backend/**
+ pull_request:
+ # The branches below must be a subset of the branches above
+ branches: ["main"]
+
+jobs:
+ python:
+ name: Python checks
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
+ - name: Install dependencies
+ run: |
+ cd ./backend
+ python -m pip install --upgrade pip
+ pip install ruff
+ pip install .[typed,test]
+ - name: Check style with Ruff
+ continue-on-error: true
+ id: ruff
+ run: |
+ cd ./backend
+ ruff check --output-format=github .
+ - name: Check type hints with mypy
+ continue-on-error: true
+ id: mypy
+ run: |
+ cd ./backend
+ mypy --show-error-codes --check-untyped-defs --config-file ./pyproject.toml .
+ - name: Test with pytest
+ env:
+ PYTEST_ADDOPTS: "--color=yes"
+ run: |
+ cd ./backend
+ coverage run -m pytest -v
+ - name: Check for failures
+ if: steps.ruff.outcome == 'failure' || steps.mypy.outcome == 'failure'
+ run: |
+ echo "One or more checks failed"
+ exit 1
diff --git a/.gitignore b/.gitignore
index f5af43dd..c0b219e9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,183 +1,184 @@
-.conda/*
-local_data/*
-beets_flask/beets/*
-tinker/*
-static/bootstrap/*
-static/bootstrap-icons/*
-docker-compose-custom.yaml
-local
-
-**/package-lock.json
-**/vite.config.ts.timestamp-*.mjs
-*.code-workspace
-*.rdb
-
-# Byte-compiled / optimized / DLL files
-__pycache__/
-*.py[cod]
-*$py.class
-
-# C extensions
-*.so
-
-# Distribution / packaging
-.Python
-build/
-develop-eggs/
-dist/
-downloads/
-eggs/
-.eggs/
-lib64/
-parts/
-sdist/
-var/
-wheels/
-share/python-wheels/
-*.egg-info/
-.installed.cfg
-*.egg
-MANIFEST
-
-.ruff_cache/
-
-# PyInstaller
-# Usually these files are written by a python script from a template
-# before PyInstaller builds the exe, so as to inject date/other infos into it.
-*.manifest
-*.spec
-
-# Installer logs
-pip-log.txt
-pip-delete-this-directory.txt
-
-# Unit test / coverage reports
-htmlcov/
-.tox/
-.nox/
-.coverage
-.coverage.*
-.cache
-nosetests.xml
-coverage.xml
-*.cover
-*.py,cover
-.hypothesis/
-.pytest_cache/
-cover/
-
-# Translations
-*.mo
-*.pot
-
-# Django stuff:
-*.log
-local_settings.py
-db.sqlite3
-db.sqlite3-journal
-
-# Quart stuff:
-instance/
-.webassets-cache
-
-# Scrapy stuff:
-.scrapy
-
-# Sphinx documentation
-docs/_build/
-
-# PyBuilder
-.pybuilder/
-target/
-
-# Jupyter Notebook
-.ipynb_checkpoints
-
-# IPython
-profile_default/
-ipython_config.py
-
-# pyenv
-# For a library or package, you might want to ignore these files since the code is
-# intended to run in multiple environments; otherwise, check them in:
-# .python-version
-
-# pipenv
-# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
-# However, in case of collaboration, if having platform-specific dependencies or dependencies
-# having no cross-platform support, pipenv may install dependencies that don't work, or not
-# install all needed dependencies.
-#Pipfile.lock
-
-# poetry
-# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
-# This is especially recommended for binary packages to ensure reproducibility, and is more
-# commonly ignored for libraries.
-# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
-#poetry.lock
-
-# pdm
-# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
-#pdm.lock
-# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
-# in version control.
-# https://pdm.fming.dev/#use-with-ide
-.pdm.toml
-
-# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
-__pypackages__/
-
-# Celery stuff
-celerybeat-schedule
-celerybeat.pid
-
-# SageMath parsed files
-*.sage.py
-
-# Environments
-.env
-.venv
-env/
-venv/
-ENV/
-env.bak/
-venv.bak/
-
-# Spyder project settings
-.spyderproject
-.spyproject
-
-# Rope project settings
-.ropeproject
-
-# mkdocs documentation
-/site
-
-# mypy
-.mypy_cache/
-.dmypy.json
-dmypy.json
-
-# Pyre type checker
-.pyre/
-
-# pytype static type analyzer
-.pytype/
-
-# Cython debug symbols
-cython_debug/
-
-# PyCharm
-# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
-# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
-# and can be added to the global gitignore or merged into this file. For a more nuclear
-# option (not recommended) you can uncomment the following to ignore the entire idea folder.
-#.idea/
-node_modules/
-.pnpm-store/
-
-
-.vscode/
-
-# TODO: Pickle for tests contains local paths and has to be ignored until fixed
-lookup*.pickle
+.conda/*
+local_data/*
+beets_flask/beets/*
+tinker/*
+static/bootstrap/*
+static/bootstrap-icons/*
+docker-compose-custom.yaml
+local
+
+**/package-lock.json
+**/vite.config.ts.timestamp-*.mjs
+*.code-workspace
+*.rdb
+
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+.ruff_cache/
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Quart stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+# For a library or package, you might want to ignore these files since the code is
+# intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+# However, in case of collaboration, if having platform-specific dependencies or dependencies
+# having no cross-platform support, pipenv may install dependencies that don't work, or not
+# install all needed dependencies.
+#Pipfile.lock
+
+# poetry
+# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+# This is especially recommended for binary packages to ensure reproducibility, and is more
+# commonly ignored for libraries.
+# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+#poetry.lock
+
+# pdm
+# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
+#pdm.lock
+# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
+# in version control.
+# https://pdm.fming.dev/#use-with-ide
+.pdm.toml
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+# and can be added to the global gitignore or merged into this file. For a more nuclear
+# option (not recommended) you can uncomment the following to ignore the entire idea folder.
+#.idea/
+node_modules/
+.pnpm-store/
+
+
+.vscode/
+
+# TODO: Pickle for tests contains local paths and has to be ignored until fixed
+lookup*.pickle
+.claude/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 63b14b3e..fb73f4da 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,11 +1,11 @@
-repos:
- - repo: https://github.com/astral-sh/ruff-pre-commit
- # Ruff version.
- rev: v0.11.2
- hooks:
- # Run the linter.
- - id: ruff
- args: [--fix, --config=backend/pyproject.toml]
- # Run the formatter.
- - id: ruff-format
- args: [--config=backend/pyproject.toml]
+repos:
+ - repo: https://github.com/astral-sh/ruff-pre-commit
+ # Ruff version.
+ rev: v0.11.2
+ hooks:
+ # Run the linter.
+ - id: ruff
+ args: [--fix, --config=backend/pyproject.toml]
+ # Run the formatter.
+ - id: ruff-format
+ args: [--config=backend/pyproject.toml]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d83f9012..d6f51b8f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,275 +1,275 @@
-# Changelog
-
-All notable changes to this project will be documented in this file.
-
-The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
-and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-
-## [1.2.1] - 25-12-28
-
-### Fixed
-
-- Workaround for semicolon character not appearing in web terminal [#243](https://github.com/pSpitzner/beets-flask/issues/243)
-
-## [1.2.0] - 25-12-17
-
-### ⚠️ Important ⚠️
-
-- You need to update your beets config, and add `musicbrainz` to the list of enabled plugins. This is required because we updated the beets backend [see here](https://github.com/beetbox/beets/releases/tag/v2.4.0)
-
-### Fixed
-
-- Duration component could display nothing in some edge cases.
-- Action buttons now have a slight box shadow and are a bit more visible.
-- We now support playing container types [m4a, mp4, mov, alac, aac, mp3] that require seeking. This should fix issues with some mp4/m4a files not playing.
-- If no candidates are found during an import, we now show a message instead of an empty screen. [#190](https://github.com/pSpitzner/beets-flask/issues/190)
-- Archive files can now be deleted [#217](https://github.com/pSpitzner/beets-flask/issues/217)
-- Import Bootleg Button now works as expected [#218](https://github.com/pSpitzner/beets-flask/issues/218)
-- Startup script was not executed correctly if placed in `/config/beets-flask/startup.sh` [#227](https://github.com/pSpitzner/beets-flask/pull/227)
-- Another state-related bug around Searching for Candidates [#225](https://github.com/pSpitzner/beets-flask/issues/225). We now no longer require a certaint type of state before allowing to add candidates.
-- Asis candidates have been restyled to be more consistent with other candidate types. They now also include a cover art preview if available.
-- Fixed a typo in our opiniated beets config [#235](https://github.com/pSpitzner/beets-flask/issues/235)
-- Fixed a styling issue in the artists list view which caused an overflow
-- Fixed a styling issue where an extra cancel-button (cross) was shown on webkit browsers
-
-### Added
-
-- Added ability to define more groups to take care of ACLs, so that our non-root user can delete files on host systems, if desired. New environment variable `EXTRA_GROUPS` [#234](https://github.com/pSpitzner/beets-flask/issues/234)
-- The inbox info button now has a description of all actions [#145](https://github.com/pSpitzner/beets-flask/issues/145)
-- Subpage for version information and configs. You can access it via the version number in the navbar. [#205](https://github.com/pSpitzner/beets-flask/issues/205)
-- New config option `gui.inbox.debounce_before_autotag` to configure how many seconds to wait after the last filesystem event before starting autotagging. Same debounce applies to all inboxes. [#222](https://github.com/pSpitzner/beets-flask/issues/222)
-- The library view on mobile now has a button to collapse the overview (above the tabs). This allows for more space when browsing the library on small screens.
-
-### Other (dev)
-
-- The default beets config now includes a `musicbrainz` section that enables fetching of external ids (like tidal).
-- Fixed typing issues in `./tests` folder and enabled mypy check for it.
-- Ruff now has the F401 (imported but unused) check enabled.
-- Ruff now had the UP checks enabled to enforce modern python syntax.
-- Unified coverart components in the frontend, we now use common styling for external and internal coverart.
-- Moved inbox metadata fetching into the library api routes.
-- Frontend formatter prettier is now enforced via a CI/CD workflow step
-
-### Dependencies
-
-- Updated `uvicorn` to `0.36.0`.
-- Updated `beets` from `2.3.1` over [`2.4.0`](https://github.com/beetbox/beets/releases/tag/v2.4.0) to [`2.5.0`](https://github.com/beetbox/beets/releases/tag/v2.5.0). See the two changelogs!
-- Updated a number of frontend dependencies, including `react-query`, `react-router`, `vite`, `typescript`, `eslint`, `prettier` and others. This partially required code changes due to breaking changes in these libraries. Should not affect normal usage tho.
-
-## [1.1.3] - 25-09-18
-
-### Added
-
-- Docs now have a section on [limiations](https://beets-flask.readthedocs.io/latest/limitations.html)
-- Pip `requirements.txt` and `startup.sh` can now be placed in `/config/` or `/config/beets-flask`, the latter is installed later.
-
-### Fixed
-
-- Resolved an issue in Vite development server where pythonTypes.ts would fail to load on first start due to inconsistent indentation (tabs vs spaces). This only affected the dev environment.
-- Development Docker container now runs as the `beetle` user instead of root, improving parity with the production environment.
-- Trailing slashes in configured inbox paths no longer cause crashes. [#182](https://github.com/pSpitzner/beets-flask/issues/182)
-- The container now sets the `EDITOR` environment variable to `vi` so that `beet edit` and `beet config -e` work out of the box.
-
-
-### Dependencies
-
-- Updated `beets` to version `2.3.1`
-- Updated `py2ts` to version `0.6.1`, now uses pypi distribution instead of github repo.
-
-
-## [1.1.2] - 25-08-29
-
-### Fixed
-
-- Updated refresh_config to scan all modules for config references and overwrite them as needed to ensure consistency [#188](https://github.com/pSpitzner/beets-flask/issues/188)
-
-
-## [1.1.1] - 25-08-15
-
-### Fixed
-
-- Session cache wasn't invalidated on all folder updates. This especially fixes an issues where the watchdog would not trigger a session invalidation when a folder was deleted or renamed. [#163](https://github.com/pSpitzner/beets-flask/issues/163)
-- We now use the beets `ignore` config option to ignore files and folders in the inbox view. This allows you to ignore files like `*.tmp`, `*.log`, etc. We also allow users to define the `gui.inbox.ignore` option to customize the ignored file patterns. [#176](https://github.com/pSpitzner/beets-flask/issues/176)
-- Scrollbar for beets instructions wasn't visible on small screens.
-
-### Other (dev)
-
-- Simplified translucent scroll setup in `__root.tsx`
-
-### Added
-
-- The items/track view now shows some basic information and you may play the track from there too.
-
-## [1.1.0] - 25-07-29
-
-### Added
-
-- Support for importing archives `zip` and `tar` files. Support for `rar` and `7z` files can be added via custom startup and requirements files. See the [FAQ](https://beets-flask.readthedocs.io/latest/faq.html) for more information.
-
-### Dependencies
-
-- Updated `py2ts` to version `0.4.1`
-
-## [1.0.3] - 25-07-29
-
-### Fixed
-
-- Fixed search results not showing [#161](https://github.com/pSpitzner/beets-flask/issues/161))
-- Fixed search box not clickable on small screens [#162](https://github.com/pSpitzner/beets-flask/issues/162)
-
-## [1.0.2] - 25-07-21
-
-### Fixed
-
-- Artists separators were not regex escaped correctly, leading to issues with artists containing special characters. Additionally an empty list of separators was not handled correctly. [#159](https://github.com/pSpitzner/beets-flask/issues/159)
-
-
-## [1.0.1] - 25-07-17
-
-### Added
-
-- Configuration option for artist separator characters `gui.library.artist_separator`
-- Docs subpage for configuration (including content)
-- `typing_extensions` is now a dependency, to allow for more typing features
-- The model api routes now allows for `DELETE` requests to delete resources by id. Not used yet but will be helpful for future features.
-
-### Fixed
-
-- Styling of candidate overview (major changes were not colored)
-- For bootlegs, display of track changes after import no longer broken
-- Navigating from inbox into folder details no longer toggles selection.
-- Padding issue where navbar could block content on mobile.
-- Cache invalidation now triggers on delete folder in frontend [#138](https://github.com/pSpitzner/beets-flask/issues/138)
-- In albums and items view the clicking on artists does not return any results if the contained a separator character (e.g. `&`) [#132](https://github.com/pSpitzner/beets-flask/issues/138)
-- Cleanup old actions.tsx file, which included old unused code [#134](https://github.com/pSpitzner/beets-flask/issues/134)
-- The `cli_exit` event is now triggered after the import task is finished. This adds compatibility with some plugins which expected this event to be triggered after the import task is done. [#154](https://github.com/pSpitzner/beets-flask/issues/154).
-
-### Changed
-
-- Created `types.py` file to hold custom sqlalchemy types, and moved `IntDictType` there.
-
-## [1.0.0] - 25-07-06
-
-This is a breaking change, you will need to update your configs and delete your beets-flask
-database (**not** the beets db!).
-
-This marks a major milestone for beets-flask, as we now pretty happy with the current features
-and the overall architecture.
-
-### Changed
-
-- Migrated backend to quart (the async version of flask)
-- Reworked most of the frontend
-- Removed interactive imports. We now store states for _any_ preview and import that is generated. Thus, sessions are resumable, and we can go back and forth seemlessly, to e.g. undo an import and pick a better candidate.
-- Inbox types have changed. For now we only have `preview`, `auto` and `bootleg`.
-- beets updated to version 2.2.0
-- Implemented our own async pipeline for beets, that is typed and handles our custom sessions (should become obsolete once upstream PRs are merged).
-- Improved library view, and track preview / streaming.
-- Improved candidate preview, including cover art and asis details (current metadata).
-- Terminal now has a bit of scroll-back and history.
-- Much better test coverage.
-- Now using [py2ts](https://github.com/semohr/py2ts) to automatically generate frontend (typescript) types from their backend (python) equivalents.
-- New and improved logo.
-
-## [0.1.1] - 25-06-08
-
-Small version bump with fixes before jumping to 1.0.0.
-
-### Added
-
-- Option to install beets plugins by placing either `requirements.txt` or `startup.sh` in /`config`. cf. [Readthedocs](https://beets-flask.readthedocs.io/en/latest/plugins.html)
-- [Documentation](https://beets-flask.readthedocs.io/en/latest/?badge=latest) on readthedocs.
-- Option to import Asis via right-click, or as inbox type. Good for Bootlegs that do not
- have online meta data and you curate manually. Currently also applies `--group-albums`.
-
-### Fixed
-
-- Path escaping for right-click import via cli (#51)
-
-## [0.1.0] - 24-11-13
-
-### Fixed
-
-- Renamed `kind` to `type` in search frontend code to be consistent with backend.
- Using kind for tags (preview, import, auto), and types for search (album, track).
-
-### Changed
-
-- Improved readme and onboarding experience
-- Mountpoint to persist config files and databases changed to `/config` (was `/home/beetle/.config/beets/`)
- We create the `/config/beets` and `/config/beets-flask` folders on startup if they do not exist.
- Library files are placed there, and you can drop a `config.yaml` either or both of these folders. Settings in `/config/beets-flask/config.yaml` take precedence over `/config/beets/config.yaml`.
- **You will need to update your docker-compose!**
-
-### Added
-
-- Logo and favicon
-- Image now on docker hub: `pspitzner/beets-flask:stable`
-- Auto-import: automatically import folders that are added to the inbox if the match is good enough.
- After a preview, import will start if the match quality is above the configured.
- Enable via the config.yaml, set the `autotag` field of a configred inbox folders to `"auto"`.
-
-## [0.0.4] - 24-10-04
-
-### Fixed
-
-- Config parsing should now work
-
-### Added
-
-- multi-disc albums are now supported
-- Interactive import using a custom beets pipeline
-
-### Changed
-
-- Moved terminal to its own page, had to temporarily remove keyboard trigger
-- Reworked the album folder detection algorithm, now uses more native beets code and is a bit faster
-- Navbar styling and items overhaul
-
-## [0.0.3] - 24-08-01
-
-### Fixed
-
-- default config: mandatory fields cannot be set in the yaml, or they
- might persist although the user sets them. moved to config loading in python.
-- tmux session now restarts on page load if it is not alive.
-- navbar, tags, inbox are now more friendly for mobile
-- folder paths are now better escaped for terminal imports
-
-### Added
-
-- Backend to get cover art from metadata of music files.
-- Impoved library view (mobile friendly, and a browser header component)
-- Library search
-
-### Changed
-
-- Simplified folder structure of frontend
-- Removed `include_paths` option from config and library backend (most of the frontend needs some form of file paths. thus, the option was not / could not be respected consistently)
-
-## [0.0.2] - 24-07-16
-
-### Fixed
-
-- ESLint errors and Github action
-- Now loading the default config
-
-## 0.0.1 - 24-05-22
-
-- initial commit
-
-
-[Unreleased]: https://github.com/pSpitzner/beets-flask/compare/v1.2.0...HEAD
-[1.2.0]: https://github.com/pSpitzner/beets-flask/compare/v1.1.3...v1.2.0
-[1.1.3]: https://github.com/pSpitzner/beets-flask/compare/v1.1.2...v1.1.3
-[1.1.2]: https://github.com/pSpitzner/beets-flask/compare/v1.1.1...v1.1.2
-[1.1.1]: https://github.com/pSpitzner/beets-flask/compare/v1.1.0...v1.1.1
-[1.1.0]: https://github.com/pSpitzner/beets-flask/compare/v1.0.3...v1.1.0
-[1.0.3]: https://github.com/pSpitzner/beets-flask/compare/v1.0.2...v1.0.3
-[1.0.2]: https://github.com/pSpitzner/beets-flask/compare/v1.0.1...v1.0.2
-[1.0.1]: https://github.com/pSpitzner/beets-flask/compare/v1.0.0...v1.0.1
-[1.0.0]: https://github.com/pSpitzner/beets-flask/compare/v0.1.0...v1.0.0
-[0.1.0]: https://github.com/pSpitzner/beets-flask/compare/v0.0.4...v0.1.0
-[0.0.4]: https://github.com/pSpitzner/beets-flask/compare/v0.0.3...v0.0.4
-[0.0.3]: https://github.com/pSpitzner/beets-flask/compare/v0.0.2...v0.0.3
-[0.0.2]: https://github.com/pSpitzner/beets-flask/compare/v0.0.1...v0.0.2
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [1.2.1] - 25-12-28
+
+### Fixed
+
+- Workaround for semicolon character not appearing in web terminal [#243](https://github.com/pSpitzner/beets-flask/issues/243)
+
+## [1.2.0] - 25-12-17
+
+### ⚠️ Important ⚠️
+
+- You need to update your beets config, and add `musicbrainz` to the list of enabled plugins. This is required because we updated the beets backend [see here](https://github.com/beetbox/beets/releases/tag/v2.4.0)
+
+### Fixed
+
+- Duration component could display nothing in some edge cases.
+- Action buttons now have a slight box shadow and are a bit more visible.
+- We now support playing container types [m4a, mp4, mov, alac, aac, mp3] that require seeking. This should fix issues with some mp4/m4a files not playing.
+- If no candidates are found during an import, we now show a message instead of an empty screen. [#190](https://github.com/pSpitzner/beets-flask/issues/190)
+- Archive files can now be deleted [#217](https://github.com/pSpitzner/beets-flask/issues/217)
+- Import Bootleg Button now works as expected [#218](https://github.com/pSpitzner/beets-flask/issues/218)
+- Startup script was not executed correctly if placed in `/config/beets-flask/startup.sh` [#227](https://github.com/pSpitzner/beets-flask/pull/227)
+- Another state-related bug around Searching for Candidates [#225](https://github.com/pSpitzner/beets-flask/issues/225). We now no longer require a certaint type of state before allowing to add candidates.
+- Asis candidates have been restyled to be more consistent with other candidate types. They now also include a cover art preview if available.
+- Fixed a typo in our opiniated beets config [#235](https://github.com/pSpitzner/beets-flask/issues/235)
+- Fixed a styling issue in the artists list view which caused an overflow
+- Fixed a styling issue where an extra cancel-button (cross) was shown on webkit browsers
+
+### Added
+
+- Added ability to define more groups to take care of ACLs, so that our non-root user can delete files on host systems, if desired. New environment variable `EXTRA_GROUPS` [#234](https://github.com/pSpitzner/beets-flask/issues/234)
+- The inbox info button now has a description of all actions [#145](https://github.com/pSpitzner/beets-flask/issues/145)
+- Subpage for version information and configs. You can access it via the version number in the navbar. [#205](https://github.com/pSpitzner/beets-flask/issues/205)
+- New config option `gui.inbox.debounce_before_autotag` to configure how many seconds to wait after the last filesystem event before starting autotagging. Same debounce applies to all inboxes. [#222](https://github.com/pSpitzner/beets-flask/issues/222)
+- The library view on mobile now has a button to collapse the overview (above the tabs). This allows for more space when browsing the library on small screens.
+
+### Other (dev)
+
+- The default beets config now includes a `musicbrainz` section that enables fetching of external ids (like tidal).
+- Fixed typing issues in `./tests` folder and enabled mypy check for it.
+- Ruff now has the F401 (imported but unused) check enabled.
+- Ruff now had the UP checks enabled to enforce modern python syntax.
+- Unified coverart components in the frontend, we now use common styling for external and internal coverart.
+- Moved inbox metadata fetching into the library api routes.
+- Frontend formatter prettier is now enforced via a CI/CD workflow step
+
+### Dependencies
+
+- Updated `uvicorn` to `0.36.0`.
+- Updated `beets` from `2.3.1` over [`2.4.0`](https://github.com/beetbox/beets/releases/tag/v2.4.0) to [`2.5.0`](https://github.com/beetbox/beets/releases/tag/v2.5.0). See the two changelogs!
+- Updated a number of frontend dependencies, including `react-query`, `react-router`, `vite`, `typescript`, `eslint`, `prettier` and others. This partially required code changes due to breaking changes in these libraries. Should not affect normal usage tho.
+
+## [1.1.3] - 25-09-18
+
+### Added
+
+- Docs now have a section on [limiations](https://beets-flask.readthedocs.io/latest/limitations.html)
+- Pip `requirements.txt` and `startup.sh` can now be placed in `/config/` or `/config/beets-flask`, the latter is installed later.
+
+### Fixed
+
+- Resolved an issue in Vite development server where pythonTypes.ts would fail to load on first start due to inconsistent indentation (tabs vs spaces). This only affected the dev environment.
+- Development Docker container now runs as the `beetle` user instead of root, improving parity with the production environment.
+- Trailing slashes in configured inbox paths no longer cause crashes. [#182](https://github.com/pSpitzner/beets-flask/issues/182)
+- The container now sets the `EDITOR` environment variable to `vi` so that `beet edit` and `beet config -e` work out of the box.
+
+
+### Dependencies
+
+- Updated `beets` to version `2.3.1`
+- Updated `py2ts` to version `0.6.1`, now uses pypi distribution instead of github repo.
+
+
+## [1.1.2] - 25-08-29
+
+### Fixed
+
+- Updated refresh_config to scan all modules for config references and overwrite them as needed to ensure consistency [#188](https://github.com/pSpitzner/beets-flask/issues/188)
+
+
+## [1.1.1] - 25-08-15
+
+### Fixed
+
+- Session cache wasn't invalidated on all folder updates. This especially fixes an issues where the watchdog would not trigger a session invalidation when a folder was deleted or renamed. [#163](https://github.com/pSpitzner/beets-flask/issues/163)
+- We now use the beets `ignore` config option to ignore files and folders in the inbox view. This allows you to ignore files like `*.tmp`, `*.log`, etc. We also allow users to define the `gui.inbox.ignore` option to customize the ignored file patterns. [#176](https://github.com/pSpitzner/beets-flask/issues/176)
+- Scrollbar for beets instructions wasn't visible on small screens.
+
+### Other (dev)
+
+- Simplified translucent scroll setup in `__root.tsx`
+
+### Added
+
+- The items/track view now shows some basic information and you may play the track from there too.
+
+## [1.1.0] - 25-07-29
+
+### Added
+
+- Support for importing archives `zip` and `tar` files. Support for `rar` and `7z` files can be added via custom startup and requirements files. See the [FAQ](https://beets-flask.readthedocs.io/latest/faq.html) for more information.
+
+### Dependencies
+
+- Updated `py2ts` to version `0.4.1`
+
+## [1.0.3] - 25-07-29
+
+### Fixed
+
+- Fixed search results not showing [#161](https://github.com/pSpitzner/beets-flask/issues/161))
+- Fixed search box not clickable on small screens [#162](https://github.com/pSpitzner/beets-flask/issues/162)
+
+## [1.0.2] - 25-07-21
+
+### Fixed
+
+- Artists separators were not regex escaped correctly, leading to issues with artists containing special characters. Additionally an empty list of separators was not handled correctly. [#159](https://github.com/pSpitzner/beets-flask/issues/159)
+
+
+## [1.0.1] - 25-07-17
+
+### Added
+
+- Configuration option for artist separator characters `gui.library.artist_separator`
+- Docs subpage for configuration (including content)
+- `typing_extensions` is now a dependency, to allow for more typing features
+- The model api routes now allows for `DELETE` requests to delete resources by id. Not used yet but will be helpful for future features.
+
+### Fixed
+
+- Styling of candidate overview (major changes were not colored)
+- For bootlegs, display of track changes after import no longer broken
+- Navigating from inbox into folder details no longer toggles selection.
+- Padding issue where navbar could block content on mobile.
+- Cache invalidation now triggers on delete folder in frontend [#138](https://github.com/pSpitzner/beets-flask/issues/138)
+- In albums and items view the clicking on artists does not return any results if the contained a separator character (e.g. `&`) [#132](https://github.com/pSpitzner/beets-flask/issues/138)
+- Cleanup old actions.tsx file, which included old unused code [#134](https://github.com/pSpitzner/beets-flask/issues/134)
+- The `cli_exit` event is now triggered after the import task is finished. This adds compatibility with some plugins which expected this event to be triggered after the import task is done. [#154](https://github.com/pSpitzner/beets-flask/issues/154).
+
+### Changed
+
+- Created `types.py` file to hold custom sqlalchemy types, and moved `IntDictType` there.
+
+## [1.0.0] - 25-07-06
+
+This is a breaking change, you will need to update your configs and delete your beets-flask
+database (**not** the beets db!).
+
+This marks a major milestone for beets-flask, as we now pretty happy with the current features
+and the overall architecture.
+
+### Changed
+
+- Migrated backend to quart (the async version of flask)
+- Reworked most of the frontend
+- Removed interactive imports. We now store states for _any_ preview and import that is generated. Thus, sessions are resumable, and we can go back and forth seemlessly, to e.g. undo an import and pick a better candidate.
+- Inbox types have changed. For now we only have `preview`, `auto` and `bootleg`.
+- beets updated to version 2.2.0
+- Implemented our own async pipeline for beets, that is typed and handles our custom sessions (should become obsolete once upstream PRs are merged).
+- Improved library view, and track preview / streaming.
+- Improved candidate preview, including cover art and asis details (current metadata).
+- Terminal now has a bit of scroll-back and history.
+- Much better test coverage.
+- Now using [py2ts](https://github.com/semohr/py2ts) to automatically generate frontend (typescript) types from their backend (python) equivalents.
+- New and improved logo.
+
+## [0.1.1] - 25-06-08
+
+Small version bump with fixes before jumping to 1.0.0.
+
+### Added
+
+- Option to install beets plugins by placing either `requirements.txt` or `startup.sh` in /`config`. cf. [Readthedocs](https://beets-flask.readthedocs.io/en/latest/plugins.html)
+- [Documentation](https://beets-flask.readthedocs.io/en/latest/?badge=latest) on readthedocs.
+- Option to import Asis via right-click, or as inbox type. Good for Bootlegs that do not
+ have online meta data and you curate manually. Currently also applies `--group-albums`.
+
+### Fixed
+
+- Path escaping for right-click import via cli (#51)
+
+## [0.1.0] - 24-11-13
+
+### Fixed
+
+- Renamed `kind` to `type` in search frontend code to be consistent with backend.
+ Using kind for tags (preview, import, auto), and types for search (album, track).
+
+### Changed
+
+- Improved readme and onboarding experience
+- Mountpoint to persist config files and databases changed to `/config` (was `/home/beetle/.config/beets/`)
+ We create the `/config/beets` and `/config/beets-flask` folders on startup if they do not exist.
+ Library files are placed there, and you can drop a `config.yaml` either or both of these folders. Settings in `/config/beets-flask/config.yaml` take precedence over `/config/beets/config.yaml`.
+ **You will need to update your docker-compose!**
+
+### Added
+
+- Logo and favicon
+- Image now on docker hub: `pspitzner/beets-flask:stable`
+- Auto-import: automatically import folders that are added to the inbox if the match is good enough.
+ After a preview, import will start if the match quality is above the configured.
+ Enable via the config.yaml, set the `autotag` field of a configred inbox folders to `"auto"`.
+
+## [0.0.4] - 24-10-04
+
+### Fixed
+
+- Config parsing should now work
+
+### Added
+
+- multi-disc albums are now supported
+- Interactive import using a custom beets pipeline
+
+### Changed
+
+- Moved terminal to its own page, had to temporarily remove keyboard trigger
+- Reworked the album folder detection algorithm, now uses more native beets code and is a bit faster
+- Navbar styling and items overhaul
+
+## [0.0.3] - 24-08-01
+
+### Fixed
+
+- default config: mandatory fields cannot be set in the yaml, or they
+ might persist although the user sets them. moved to config loading in python.
+- tmux session now restarts on page load if it is not alive.
+- navbar, tags, inbox are now more friendly for mobile
+- folder paths are now better escaped for terminal imports
+
+### Added
+
+- Backend to get cover art from metadata of music files.
+- Impoved library view (mobile friendly, and a browser header component)
+- Library search
+
+### Changed
+
+- Simplified folder structure of frontend
+- Removed `include_paths` option from config and library backend (most of the frontend needs some form of file paths. thus, the option was not / could not be respected consistently)
+
+## [0.0.2] - 24-07-16
+
+### Fixed
+
+- ESLint errors and Github action
+- Now loading the default config
+
+## 0.0.1 - 24-05-22
+
+- initial commit
+
+
+[Unreleased]: https://github.com/pSpitzner/beets-flask/compare/v1.2.0...HEAD
+[1.2.0]: https://github.com/pSpitzner/beets-flask/compare/v1.1.3...v1.2.0
+[1.1.3]: https://github.com/pSpitzner/beets-flask/compare/v1.1.2...v1.1.3
+[1.1.2]: https://github.com/pSpitzner/beets-flask/compare/v1.1.1...v1.1.2
+[1.1.1]: https://github.com/pSpitzner/beets-flask/compare/v1.1.0...v1.1.1
+[1.1.0]: https://github.com/pSpitzner/beets-flask/compare/v1.0.3...v1.1.0
+[1.0.3]: https://github.com/pSpitzner/beets-flask/compare/v1.0.2...v1.0.3
+[1.0.2]: https://github.com/pSpitzner/beets-flask/compare/v1.0.1...v1.0.2
+[1.0.1]: https://github.com/pSpitzner/beets-flask/compare/v1.0.0...v1.0.1
+[1.0.0]: https://github.com/pSpitzner/beets-flask/compare/v0.1.0...v1.0.0
+[0.1.0]: https://github.com/pSpitzner/beets-flask/compare/v0.0.4...v0.1.0
+[0.0.4]: https://github.com/pSpitzner/beets-flask/compare/v0.0.3...v0.0.4
+[0.0.3]: https://github.com/pSpitzner/beets-flask/compare/v0.0.2...v0.0.3
+[0.0.2]: https://github.com/pSpitzner/beets-flask/compare/v0.0.1...v0.0.2
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000..f23eb71f
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,93 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## What is beets-flask
+
+A web UI for [beets](https://beets.io/), the music library manager. It adds a browser-based interface for previewing and confirming imports, searching the library, undoing imports, and managing the inbox — all backed by beets under the hood.
+
+## Architecture
+
+The app is composed of four cooperating processes, typically run together via Docker:
+
+1. **Quart server** (`backend/beets_flask/server/`) — async Python HTTP/WebSocket server. REST API under `/api_v1/`, real-time events via Socket.io. Entry point: `beets_flask.server.app:create_app` (factory pattern).
+2. **RQ workers** (`backend/launch_redis_workers.py`) — background jobs for import and preview generation, backed by Redis.
+3. **Watchdog worker** (`backend/launch_watchdog_worker.py`) — monitors the inbox directory for new files and enqueues previews.
+4. **React SPA** (`frontend/`) — served by Vite in dev mode (port 5173, proxies API/WS to port 5001) or as static files in production.
+
+**Data stores:**
+- `/config/beets/library.db` — beets' own SQLite music library
+- `/config/beets-flask/beets-flask-sqlite.db` — beets-flask app state
+- Redis — RQ job queues (`import`, `preview`)
+
+**Frontend → backend communication:** REST JSON (`/api_v1/`) + Socket.io WebSocket (`/socket.io/`). In dev mode Vite proxies both to `localhost:5001`.
+
+**Type sharing:** `backend/generate_types.py` generates TypeScript types from Python definitions. Run it after changing shared data shapes.
+
+## Commands
+
+### Backend
+
+```bash
+cd backend
+pip install -e .[dev,test] # install with dev + test extras
+
+ruff check . # lint
+ruff format . # format
+mypy . # type-check
+
+pytest # all tests
+pytest tests/unit/test_foo.py::test_bar # single test
+coverage run -m pytest && coverage report
+```
+
+### Frontend
+
+```bash
+cd frontend
+pnpm install
+
+pnpm run dev # Vite dev server on :5173
+pnpm run build # production build
+pnpm run lint # ESLint
+pnpm run format # Prettier
+pnpm run check-types # tsc
+pnpm run analyze # bundle visualizer
+```
+
+### Docker (typical dev workflow)
+
+```bash
+docker compose -f docker/docker-compose.dev.yaml up # dev (live reload)
+docker compose -f docker/docker-compose.yaml up # production
+docker compose -f docker/docker-compose.tests.yaml up # run tests in container
+```
+
+## Testing
+
+Backend tests live in `backend/tests/`, split into `unit/` and `integration/`. Key fixtures in `backend/tests/conftest.py`:
+
+- `testapp` / `client` — Quart test app + HTTP client
+- `db_session` — SQLAlchemy session against a temp database
+- `beets_lib` — real beets library pointing at test audio files in `tests/data/audio/`
+- `local_redis` — `FakeStrictRedis` so no real Redis is needed
+
+`asyncio_mode = "auto"` is set globally, so async test functions work without decoration.
+
+## Code conventions
+
+**Python:** Ruff (linter + formatter) and mypy with `check_untyped_defs`. NumPy-style docstrings, imperative mood. Pre-commit hooks auto-fix Ruff issues.
+
+**TypeScript/React:** ESLint + Prettier (zero warnings in CI). Package manager is **pnpm** — do not use npm or yarn.
+
+**API responses:** a custom JSON encoder in the backend handles `bytes`, `datetime`, `Enum`, and `dataclass` types — avoid plain `dict` responses that bypass it.
+
+## Key config environment variables
+
+| Variable | Purpose |
+|---|---|
+| `IB_SERVER_CONFIG` | Server mode: `dev`, `test`, or `prod` |
+| `BEETSDIR` | Path to beets config directory |
+| `BEETSFLASKDIR` | Path to beets-flask config/state directory |
+| `LOG_LEVEL_*` | Per-module log levels |
+| `USER_ID` / `GROUP_ID` | Container user (preserve file ownership) |
diff --git a/LICENSE b/LICENSE
index 3956bac3..85e712c4 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,21 +1,21 @@
-The MIT License
-
-Copyright (c) 2024 F. Paul Spitzner, Sebastian B. Mohr
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
+The MIT License
+
+Copyright (c) 2024 F. Paul Spitzner, Sebastian B. Mohr
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000..03ee88a4
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,8 @@
+build:
+ sudo docker build -f docker/Dockerfile -t shlagi-tagger:latest .
+
+update_config:
+ cp -r config/beets/. /volume1/docker/Music/beets-flask/config/beets/
+ cp -r config/beets-flask/. /volume1/docker/Music/beets-flask/config/beets-flask/
+ cp config/startup.sh /volume1/docker/Music/beets-flask/config/startup.sh
+ chmod +x /volume1/docker/Music/beets-flask/config/startup.sh
diff --git a/README.md b/README.md
index 8e62a0cb..773922df 100644
--- a/README.md
+++ b/README.md
@@ -1,121 +1,121 @@
-
-
-
Beets-flask
-
-
-[](https://github.com/pSpitzner/beets-flask/blob/main/CHANGELOG.md)
-[](https://hub.docker.com/r/pspitzner/beets-flask/tags)
-[](https://opensource.org/licenses/MIT)
-[](https://github.com/pSpitzner/beets-flask/pkgs/container/beets-flask)
-[](https://beets-flask.readthedocs.io/en/latest/?badge=latest)
-
-
- Opinionated web-interface around the music organizer beets
-
-
-
-
-## Features
-
-
-
-- Autogenerate previews before importing
-- Auto-Import good matches
-- Import via GUI
-- Undo imports
-- Web-Terminal
-- Monitor multiple inboxes
-- Library view and search
-
-
-
-https://github.com/user-attachments/assets/b7c7ae32-36ac-4950-b2ed-186b80d7075b
-
-## Motivation
-
-
-
-Autotagging music with beets is great. Beets identifies metadata correctly _most_ of the time, and if you are not a control-freak, there is hardly any reason to check the found metadata.
-
-However, if you do want a bit more control, things could be more convenient.
-
-This is the main idea with beets-flask: For all folders in your inbox, we generate a preview of what beets _would do_ and show you those previews. Then it's easy to go through them and import the correct ones, or pick other candidates for those that were not to your liking.
-
-
-
-## Quickstart
-
-We provide a docker image with the full beeets-flask setup. You can run it with docker-compose or docker. We recommend using the `stable` tag, alternatively you may use `latest` for the most recent build.
-
-
-### Setup container
-
-**Using docker**
-
-
-
-```sh
-docker run -d -p 5001:5001 \
- -e TZ=Europe/Berlin \
- -e USER_ID=1000 \
- -e GROUP_ID=1000 \
- -v /wherever/config/:/config \
- -v /music_path/inbox/:/music_path/inbox/ \
- -v /music_path/clean/:/music_path/clean/ \
- --name beets-flask \
- pspitzner/beets-flask:stable
-```
-
-
-
-**Using docker compose**
-
-```yaml
-services:
- beets-flask:
- image: pspitzner/beets-flask:stable
- restart: unless-stopped
- ports:
- - "5001:5001"
- environment:
- # 502 is default on macos, 1000 on linux
- TZ: Europe/Berlin
- USER_ID: 1000
- GROUP_ID: 1000
- volumes:
- - /wherever/config/:/config
- # for music folders, match paths inside and out of container!
- - /music_path/inbox/:/music_path/inbox/
- - /music_path/clean/:/music_path/clean/
-```
-
-This will create a container with the following folder structure:
-
-```
-├── music_path
-│ ├── inbox
-│ └── clean
-└── config
- ├── beets
- │ ├── config.yaml
- │ └── library.db
- └── beets-flask
- ├── config.yaml
- └── beets-flask-sqlite.db
-```
-
-Check our [**documentation**](https://beets-flask.readthedocs.io/en/latest/) for more information!
-
-## Limitations
-
-There are a few known issues, see the corresponding [docs section](https://beets-flask.readthedocs.io/latest/limitations.html) for details.
-
-## Support the Project
-
-If you enjoy this project, there are a few ways you can support us:
-
-- **Contribute code:** Pull requests, bug reports, and feature suggestions are always welcome!
-- **Spread the word:** Share the project with friends or on social media.
-- **Donate:** Every contribution helps fuel more coffee-powered coding sessions!
- - **Donate ETH:** [0x81927e76f2f0fAA9e7fD92176a473955DB20Ce55](ethereum:0x81927e76f2f0fAA9e7fD92176a473955DB20Ce55)
+
+
+
Beets-flask
+
+
+[](https://github.com/pSpitzner/beets-flask/blob/main/CHANGELOG.md)
+[](https://hub.docker.com/r/pspitzner/beets-flask/tags)
+[](https://opensource.org/licenses/MIT)
+[](https://github.com/pSpitzner/beets-flask/pkgs/container/beets-flask)
+[](https://beets-flask.readthedocs.io/en/latest/?badge=latest)
+
+
+ Opinionated web-interface around the music organizer beets
+
+
+
+
+## Features
+
+
+
+- Autogenerate previews before importing
+- Auto-Import good matches
+- Import via GUI
+- Undo imports
+- Web-Terminal
+- Monitor multiple inboxes
+- Library view and search
+
+
+
+https://github.com/user-attachments/assets/b7c7ae32-36ac-4950-b2ed-186b80d7075b
+
+## Motivation
+
+
+
+Autotagging music with beets is great. Beets identifies metadata correctly _most_ of the time, and if you are not a control-freak, there is hardly any reason to check the found metadata.
+
+However, if you do want a bit more control, things could be more convenient.
+
+This is the main idea with beets-flask: For all folders in your inbox, we generate a preview of what beets _would do_ and show you those previews. Then it's easy to go through them and import the correct ones, or pick other candidates for those that were not to your liking.
+
+
+
+## Quickstart
+
+We provide a docker image with the full beeets-flask setup. You can run it with docker-compose or docker. We recommend using the `stable` tag, alternatively you may use `latest` for the most recent build.
+
+
+### Setup container
+
+**Using docker**
+
+
+
+```sh
+docker run -d -p 5001:5001 \
+ -e TZ=Europe/Berlin \
+ -e USER_ID=1000 \
+ -e GROUP_ID=1000 \
+ -v /wherever/config/:/config \
+ -v /music_path/inbox/:/music_path/inbox/ \
+ -v /music_path/clean/:/music_path/clean/ \
+ --name beets-flask \
+ pspitzner/beets-flask:stable
+```
+
+
+
+**Using docker compose**
+
+```yaml
+services:
+ beets-flask:
+ image: pspitzner/beets-flask:stable
+ restart: unless-stopped
+ ports:
+ - "5001:5001"
+ environment:
+ # 502 is default on macos, 1000 on linux
+ TZ: Europe/Berlin
+ USER_ID: 1000
+ GROUP_ID: 1000
+ volumes:
+ - /wherever/config/:/config
+ # for music folders, match paths inside and out of container!
+ - /music_path/inbox/:/music_path/inbox/
+ - /music_path/clean/:/music_path/clean/
+```
+
+This will create a container with the following folder structure:
+
+```
+├── music_path
+│ ├── inbox
+│ └── clean
+└── config
+ ├── beets
+ │ ├── config.yaml
+ │ └── library.db
+ └── beets-flask
+ ├── config.yaml
+ └── beets-flask-sqlite.db
+```
+
+Check our [**documentation**](https://beets-flask.readthedocs.io/en/latest/) for more information!
+
+## Limitations
+
+There are a few known issues, see the corresponding [docs section](https://beets-flask.readthedocs.io/latest/limitations.html) for details.
+
+## Support the Project
+
+If you enjoy this project, there are a few ways you can support us:
+
+- **Contribute code:** Pull requests, bug reports, and feature suggestions are always welcome!
+- **Spread the word:** Share the project with friends or on social media.
+- **Donate:** Every contribution helps fuel more coffee-powered coding sessions!
+ - **Donate ETH:** [0x81927e76f2f0fAA9e7fD92176a473955DB20Ce55](ethereum:0x81927e76f2f0fAA9e7fD92176a473955DB20Ce55)
- **Donate BTC:** [bc1qw5e0deust6uq94e5s58au82wrakcjmlemw3cy4](bitcoin:bc1qw5e0deust6uq94e5s58au82wrakcjmlemw3cy4)
\ No newline at end of file
diff --git a/backend/beets_flask/__init__.py b/backend/beets_flask/__init__.py
index dce63995..acd8d0da 100644
--- a/backend/beets_flask/__init__.py
+++ b/backend/beets_flask/__init__.py
@@ -1,3 +1,3 @@
-from .logger import log
-
-__all__ = ["log"]
+from .logger import log
+
+__all__ = ["log"]
diff --git a/backend/beets_flask/config/__init__.py b/backend/beets_flask/config/__init__.py
index 645d4c2f..47e047c9 100644
--- a/backend/beets_flask/config/__init__.py
+++ b/backend/beets_flask/config/__init__.py
@@ -1,19 +1,19 @@
-from .beets_config import get_config
-from .flask_config import (
- DeploymentDocker,
- DevelopmentDocker,
- DevelopmentLocal,
- ServerConfig,
- Testing,
- get_flask_config,
-)
-
-__all__ = [
- "get_config",
- "get_flask_config",
- "ServerConfig",
- "Testing",
- "DevelopmentLocal",
- "DevelopmentDocker",
- "DeploymentDocker",
-]
+from .beets_config import get_config
+from .flask_config import (
+ DeploymentDocker,
+ DevelopmentDocker,
+ DevelopmentLocal,
+ ServerConfig,
+ Testing,
+ get_flask_config,
+)
+
+__all__ = [
+ "get_config",
+ "get_flask_config",
+ "ServerConfig",
+ "Testing",
+ "DevelopmentLocal",
+ "DevelopmentDocker",
+ "DeploymentDocker",
+]
diff --git a/backend/beets_flask/config/beets_config.py b/backend/beets_flask/config/beets_config.py
index 196640ea..1b65ac93 100644
--- a/backend/beets_flask/config/beets_config.py
+++ b/backend/beets_flask/config/beets_config.py
@@ -1,265 +1,265 @@
-"""Overload for beets configuration.
-
-We support setting config values either via your beets config file, under the `gui` section, or via environment variables in the Docker compose.
-
-Use double underscore to separate nested values:
-https://confuse.readthedocs.io/en/latest/usage.html#environment-variables
-
-We prefix all environment variables with `IB` to avoid conflicts with other services.
-
-To set a custom file path to a yaml that gets inserted into (and overwrites) the
-beets config, set the `IB_GUI_CONFIGPATH` environment variable.
-Note that this does not remove list keys from the lower priority default config
-(e.g. if you configure different inbox folders in your beets config, and the ib config,
-all of them will be added).
-
-# Example:
-
-```bash
-export IB_GUI__TAGS=first
-```
-
-```python
-from beets_flask.beets_config.config import config
-print(config["gui"]["tags"].get(default="default_value"))
-```
-"""
-
-import os
-import sys
-from typing import cast
-
-from beets import IncludeLazyConfig as BeetsConfig
-from beets.plugins import _instances as plugin_instances
-from beets.plugins import get_plugin_names, load_plugins
-from confuse import YamlSource
-
-from beets_flask.logger import log
-
-
-def _copy_file(src, dest):
- with open(src) as src_file, open(dest, "w") as dest_file:
- dest_file.write(src_file.read())
-
-
-class Singleton(type):
- _instances: dict = {}
-
- def __call__(cls, *args, **kwargs):
- """Singleton pattern implementation."""
- if cls not in cls._instances:
- cls._instances[cls] = super().__call__(*args, **kwargs)
- return cls._instances[cls]
-
-
-class InteractiveBeetsConfig(BeetsConfig, metaclass=Singleton):
- """Singleton class to handle the beets config.
-
- This class is a subclass of the beets config and adds some
- interactive beets specific functionality.
- """
-
- def __init__(self):
- """Initialize the config object with the default values.
-
- Loads config and some interactive beets specific tweaks.
- """
- super().__init__("beets", "beets")
- self.reset()
-
- @staticmethod
- def get_beets_flask_config_path() -> str:
- """Get the path to the beets-flask config file."""
-
- ib_folder = os.getenv("BEETSFLASKDIR")
- if ib_folder is None:
- ib_folder = os.path.expanduser("~/.config/beets-flask")
- os.makedirs(ib_folder, exist_ok=True)
- ib_config_path = os.path.join(ib_folder, "config.yaml")
- return ib_config_path
-
- @staticmethod
- def get_beets_config_path() -> str:
- """Get the path to the beets config file."""
- # TODO: maybe there is a beets function to get the path
- beets_folder = os.getenv("BEETSDIR")
- if beets_folder is None:
- beets_folder = os.path.expanduser("~/.config/beets")
- beets_config_path = os.path.join(beets_folder, "config.yaml")
- return beets_config_path
-
- def reset(self):
- """Recreate the config object.
-
- As if the app was just started.
- """
- # vanilla beets reset
- log.debug(f"Reading beets config from default location")
- self.clear()
- self.read()
-
- # read the default config just in case the user config is missing
- # or malformed
- ib_defaults_path = os.path.join(
- os.path.dirname(__file__), "config_bf_default.yaml"
- )
- log.debug(f"Reading IB config defaults from {ib_defaults_path}")
- default_source = YamlSource(ib_defaults_path, default=True)
- self.add(default_source) # .add inserts with lowest priority
-
- # then apply our needed tweaks
- # enable env variables
- self.set_env(prefix="IB")
-
- # Load config from default location (set via env var)
- # if it is set otherwise use the default location
- ib_config_path = self.get_beets_flask_config_path()
-
- # Check if the user config exists
- # if not, copy the example config to the user config location
- if not os.path.exists(ib_config_path):
- # Copy the default config to the user config location
- log.debug(f"Beets-flask config not found at {ib_config_path}")
- log.debug(f"Copying default config to {ib_config_path}")
- ib_example_path = os.path.join(
- os.path.dirname(__file__), "config_bf_example.yaml"
- )
- _copy_file(ib_example_path, ib_config_path)
-
- # Same check for beets config and copy our default
- # if it does not exist
- beets_config_path = self.get_beets_config_path()
- if not os.path.exists(beets_config_path):
- log.debug(f"Beets config not found at {beets_config_path}")
- log.debug(f"Copying default config to {beets_config_path}")
- beets_example_path = os.path.join(
- os.path.dirname(__file__), "config_b_example.yaml"
- )
- _copy_file(beets_example_path, beets_config_path)
-
- # Inserts user config at highest priority
- log.debug(f"Reading beets-flask config from {ib_config_path}")
- self.set(YamlSource(ib_config_path, default=False))
-
- # add placeholders for required keys if they are not configured,
- # so the docker container starts and can show some help.
-
- # beets does not create a config file automatically for the user. Customizations are added as extra layers on the config.
- sources = [s for s in beets.config["directory"].resolve()]
- if len(sources) == 1:
- log.debug(
- "Beets is not using a user config. Overwriting the default `directory`."
- )
- self["directory"] = "/music/imported"
-
- # TODO: would be nice to have this in the default config,
- # and simply remove it here if other inbox folders have been configured.
- # but: I do not know how to remove (some) elements from a confuse config.
- if len(self["gui"]["inbox"]["folders"].keys()) == 0:
- self["gui"]["inbox"]["folders"]["Placeholder"] = {
- "name": "Please check your config!",
- "path": "/music/inbox",
- "autotag": False,
- }
-
- # make sure to remove trailing slashes from user configured inbox paths
- for folder in self["gui"]["inbox"]["folders"].values():
- fp: str = folder["path"].as_str() # type: ignore
- if fp.endswith("/"):
- folder["path"] = fp.rstrip("/")
- log.debug(f"Removed trailing slash from inbox path: {folder['path']}")
-
- @property
- def ignore_globs(self) -> list[str]:
- """
- Get the list of ignore globs from the config.
-
- If user does not set this in their beets flask config, we use whats in beets.
- (We do this via a placeholder string "_use_beets_ignore")
- If the user sets an empty list [], that means no files are ignored.
-
- """
- gui_globs: list[str] | str = get_config()["gui"]["inbox"]["ignore"].get() # type: ignore
- if gui_globs is None or gui_globs == "_use_beets_ignore":
- gui_globs: list[str] = self["ignore"].as_str_seq() # type: ignore
- elif isinstance(gui_globs, str):
- gui_globs = [gui_globs]
- elif isinstance(gui_globs, list):
- gui_globs = gui_globs
- return cast(list[str], gui_globs)
-
-
-# Monkey patch the beets config
-import beets
-
-config: InteractiveBeetsConfig | None = None
-
-
-def refresh_config():
- """Refresh the config object.
-
- This is useful if you want to reload the config after it has been changed.
- """
- global config
-
- # Keep reference to old config
- old_config = getattr(beets, "config", None)
-
- config = InteractiveBeetsConfig()
-
- beets.config = config
- sys.modules["beets"].config = config # type: ignore
-
- # Hack: We have to manually load the plugins as this
- # is normally done by beets. Clear the list to force
- # actual reload.
- plugin_instances.clear()
- load_plugins()
- log.debug(f"Loading plugins: {get_plugin_names()}")
-
- # Update any existing references in other modules
- for module_name, mod in list(sys.modules.items()):
- if mod is None:
- continue
-
- if not (
- module_name.startswith("beets") # includes beets and beetsplug
- ):
- continue
-
- for attr_name in dir(mod):
- try:
- if getattr(mod, attr_name) is old_config:
- setattr(mod, attr_name, config)
- log.debug(f"Updated config in {module_name}.{attr_name}")
- except Exception as e:
- log.debug(f"Could not check {module_name}.{attr_name}", exc_info=e)
- continue
-
- return config
-
-
-def get_config(force_refresh=False) -> InteractiveBeetsConfig:
- """Get the config object.
-
- This is useful if you want to access the config from another module.
-
- The result of this function is still the global object that you can mutate!
-
- Parameters
- ----------
- force_refresh : bool
- Force a refresh of the config object.
- This is useful if you want to be sure that the config is up to date,
- should normally only be called if the config was changed in another process.
- """
- global config
-
- if config is None or force_refresh:
- return refresh_config()
- return config
-
-
-__all__ = ["refresh_config", "get_config"]
-
-# raise NotImplementedError("This module should not be imported.")
+"""Overload for beets configuration.
+
+We support setting config values either via your beets config file, under the `gui` section, or via environment variables in the Docker compose.
+
+Use double underscore to separate nested values:
+https://confuse.readthedocs.io/en/latest/usage.html#environment-variables
+
+We prefix all environment variables with `IB` to avoid conflicts with other services.
+
+To set a custom file path to a yaml that gets inserted into (and overwrites) the
+beets config, set the `IB_GUI_CONFIGPATH` environment variable.
+Note that this does not remove list keys from the lower priority default config
+(e.g. if you configure different inbox folders in your beets config, and the ib config,
+all of them will be added).
+
+# Example:
+
+```bash
+export IB_GUI__TAGS=first
+```
+
+```python
+from beets_flask.beets_config.config import config
+print(config["gui"]["tags"].get(default="default_value"))
+```
+"""
+
+import os
+import sys
+from typing import cast
+
+from beets import IncludeLazyConfig as BeetsConfig
+from beets.plugins import _instances as plugin_instances
+from beets.plugins import get_plugin_names, load_plugins
+from confuse import YamlSource
+
+from beets_flask.logger import log
+
+
+def _copy_file(src, dest):
+ with open(src) as src_file, open(dest, "w") as dest_file:
+ dest_file.write(src_file.read())
+
+
+class Singleton(type):
+ _instances: dict = {}
+
+ def __call__(cls, *args, **kwargs):
+ """Singleton pattern implementation."""
+ if cls not in cls._instances:
+ cls._instances[cls] = super().__call__(*args, **kwargs)
+ return cls._instances[cls]
+
+
+class InteractiveBeetsConfig(BeetsConfig, metaclass=Singleton):
+ """Singleton class to handle the beets config.
+
+ This class is a subclass of the beets config and adds some
+ interactive beets specific functionality.
+ """
+
+ def __init__(self):
+ """Initialize the config object with the default values.
+
+ Loads config and some interactive beets specific tweaks.
+ """
+ super().__init__("beets", "beets")
+ self.reset()
+
+ @staticmethod
+ def get_beets_flask_config_path() -> str:
+ """Get the path to the beets-flask config file."""
+
+ ib_folder = os.getenv("BEETSFLASKDIR")
+ if ib_folder is None:
+ ib_folder = os.path.expanduser("~/.config/beets-flask")
+ os.makedirs(ib_folder, exist_ok=True)
+ ib_config_path = os.path.join(ib_folder, "config.yaml")
+ return ib_config_path
+
+ @staticmethod
+ def get_beets_config_path() -> str:
+ """Get the path to the beets config file."""
+ # TODO: maybe there is a beets function to get the path
+ beets_folder = os.getenv("BEETSDIR")
+ if beets_folder is None:
+ beets_folder = os.path.expanduser("~/.config/beets")
+ beets_config_path = os.path.join(beets_folder, "config.yaml")
+ return beets_config_path
+
+ def reset(self):
+ """Recreate the config object.
+
+ As if the app was just started.
+ """
+ # vanilla beets reset
+ log.debug(f"Reading beets config from default location")
+ self.clear()
+ self.read()
+
+ # read the default config just in case the user config is missing
+ # or malformed
+ ib_defaults_path = os.path.join(
+ os.path.dirname(__file__), "config_bf_default.yaml"
+ )
+ log.debug(f"Reading IB config defaults from {ib_defaults_path}")
+ default_source = YamlSource(ib_defaults_path, default=True)
+ self.add(default_source) # .add inserts with lowest priority
+
+ # then apply our needed tweaks
+ # enable env variables
+ self.set_env(prefix="IB")
+
+ # Load config from default location (set via env var)
+ # if it is set otherwise use the default location
+ ib_config_path = self.get_beets_flask_config_path()
+
+ # Check if the user config exists
+ # if not, copy the example config to the user config location
+ if not os.path.exists(ib_config_path):
+ # Copy the default config to the user config location
+ log.debug(f"Beets-flask config not found at {ib_config_path}")
+ log.debug(f"Copying default config to {ib_config_path}")
+ ib_example_path = os.path.join(
+ os.path.dirname(__file__), "config_bf_example.yaml"
+ )
+ _copy_file(ib_example_path, ib_config_path)
+
+ # Same check for beets config and copy our default
+ # if it does not exist
+ beets_config_path = self.get_beets_config_path()
+ if not os.path.exists(beets_config_path):
+ log.debug(f"Beets config not found at {beets_config_path}")
+ log.debug(f"Copying default config to {beets_config_path}")
+ beets_example_path = os.path.join(
+ os.path.dirname(__file__), "config_b_example.yaml"
+ )
+ _copy_file(beets_example_path, beets_config_path)
+
+ # Inserts user config at highest priority
+ log.debug(f"Reading beets-flask config from {ib_config_path}")
+ self.set(YamlSource(ib_config_path, default=False))
+
+ # add placeholders for required keys if they are not configured,
+ # so the docker container starts and can show some help.
+
+ # beets does not create a config file automatically for the user. Customizations are added as extra layers on the config.
+ sources = [s for s in beets.config["directory"].resolve()]
+ if len(sources) == 1:
+ log.debug(
+ "Beets is not using a user config. Overwriting the default `directory`."
+ )
+ self["directory"] = "/music/imported"
+
+ # TODO: would be nice to have this in the default config,
+ # and simply remove it here if other inbox folders have been configured.
+ # but: I do not know how to remove (some) elements from a confuse config.
+ if len(self["gui"]["inbox"]["folders"].keys()) == 0:
+ self["gui"]["inbox"]["folders"]["Placeholder"] = {
+ "name": "Please check your config!",
+ "path": "/music/inbox",
+ "autotag": False,
+ }
+
+ # make sure to remove trailing slashes from user configured inbox paths
+ for folder in self["gui"]["inbox"]["folders"].values():
+ fp: str = folder["path"].as_str() # type: ignore
+ if fp.endswith("/"):
+ folder["path"] = fp.rstrip("/")
+ log.debug(f"Removed trailing slash from inbox path: {folder['path']}")
+
+ @property
+ def ignore_globs(self) -> list[str]:
+ """
+ Get the list of ignore globs from the config.
+
+ If user does not set this in their beets flask config, we use whats in beets.
+ (We do this via a placeholder string "_use_beets_ignore")
+ If the user sets an empty list [], that means no files are ignored.
+
+ """
+ gui_globs: list[str] | str = get_config()["gui"]["inbox"]["ignore"].get() # type: ignore
+ if gui_globs is None or gui_globs == "_use_beets_ignore":
+ gui_globs: list[str] = self["ignore"].as_str_seq() # type: ignore
+ elif isinstance(gui_globs, str):
+ gui_globs = [gui_globs]
+ elif isinstance(gui_globs, list):
+ gui_globs = gui_globs
+ return cast(list[str], gui_globs)
+
+
+# Monkey patch the beets config
+import beets
+
+config: InteractiveBeetsConfig | None = None
+
+
+def refresh_config():
+ """Refresh the config object.
+
+ This is useful if you want to reload the config after it has been changed.
+ """
+ global config
+
+ # Keep reference to old config
+ old_config = getattr(beets, "config", None)
+
+ config = InteractiveBeetsConfig()
+
+ beets.config = config
+ sys.modules["beets"].config = config # type: ignore
+
+ # Hack: We have to manually load the plugins as this
+ # is normally done by beets. Clear the list to force
+ # actual reload.
+ plugin_instances.clear()
+ load_plugins()
+ log.debug(f"Loading plugins: {get_plugin_names()}")
+
+ # Update any existing references in other modules
+ for module_name, mod in list(sys.modules.items()):
+ if mod is None:
+ continue
+
+ if not (
+ module_name.startswith("beets") # includes beets and beetsplug
+ ):
+ continue
+
+ for attr_name in dir(mod):
+ try:
+ if getattr(mod, attr_name) is old_config:
+ setattr(mod, attr_name, config)
+ log.debug(f"Updated config in {module_name}.{attr_name}")
+ except Exception as e:
+ log.debug(f"Could not check {module_name}.{attr_name}", exc_info=e)
+ continue
+
+ return config
+
+
+def get_config(force_refresh=False) -> InteractiveBeetsConfig:
+ """Get the config object.
+
+ This is useful if you want to access the config from another module.
+
+ The result of this function is still the global object that you can mutate!
+
+ Parameters
+ ----------
+ force_refresh : bool
+ Force a refresh of the config object.
+ This is useful if you want to be sure that the config is up to date,
+ should normally only be called if the config was changed in another process.
+ """
+ global config
+
+ if config is None or force_refresh:
+ return refresh_config()
+ return config
+
+
+__all__ = ["refresh_config", "get_config"]
+
+# raise NotImplementedError("This module should not be imported.")
diff --git a/backend/beets_flask/config/config_b_example.yaml b/backend/beets_flask/config/config_b_example.yaml
index f55d5d49..cae22eab 100644
--- a/backend/beets_flask/config/config_b_example.yaml
+++ b/backend/beets_flask/config/config_b_example.yaml
@@ -1,129 +1,129 @@
-# ------------------------------------------------------------------------------------ #
-# BEETS CONFIG #
-# ------------------------------------------------------------------------------------ #
-# Opinionated example beets configuration. This file was automatically copied to
-# /config/beets/config.yaml. Feel free to edit this file to customize the beets
-# configuration. For more information on the beets configuration, see
-# https://beets.readthedocs.io/en/stable/reference/config.html
-
-plugins: [
- info,
- the,
- fetchart,
- embedart,
- ftintitle,
- lastgenre,
- missing,
- albumtypes,
- scrub,
- zero,
- mbsync,
- duplicates,
- convert,
- fromfilename,
- inline,
- edit,
- spotify, # needs authentication https://docs.beets.io/en/latest/plugins/spotify.html
- musicbrainz, # needs to be enabled explicitly since beets 2.4.0
- ]
-
-directory: /music/imported
-# library: /config/beets/library.db # default location in the container
-
-import:
- move: no
- copy: yes
- write: yes
- log: /music/last_beets_imports.log
- quiet_fallback: skip
- detail: yes
- duplicate_action: ask # ask|skip|merge|keep|remove
-
-ui:
- color: yes
-
-# fix up output file paths
-replace:
- '[\\]': ""
- "[_]": "-"
- "[/]": "-"
- '^\.+': ""
- '[\x00-\x1f]': ""
- '[<>:"\?\*\|]': ""
- '\.$': ""
- '\s+$': ""
- '^\s+': ""
- "^-": ""
- "’": ""
- "′": ""
- "″": ""
- "‐": "-"
-
-per_disc_numbering: no
-asciify_paths: yes
-
-# adjusting the `threaded` setting of beets should currently have no effect.
-# we launch our own workers for previews and only have one
-# import worker that runs one import (file moving) at a time.
-threaded: no
-
-fetchart:
- minwidth: 500
- enforce_ratio: 10px # yes|no or tolerance around 1:1 ratio
- sources: coverart filesystem itunes amazon spotify albumart fanarttv
-
-embedart:
- auto: yes
- ifempty: yes # whether to avoid embedding album art for files that already have art embedded.
- remove_art_file: yes
-
-ftintitle:
- auto: yes
- format: (feat. {0})
-
-lastgenre: # get genres from last fm
- auto: yes
- count: 4
- prefer_specific: yes # Sort genres by the most to least specific, rather than most to least popular.
- force: yes # By default, beets will always fetch new genres, even if the files already have one
- source: track # album|track
- separator: "; "
- fallback: ""
-
-match:
- # autotagger tolerance [0, 1], default 0.04
- # for example, 0.1 means 90% similarity required.
- # inbox-folder autotag setting 'auto' respects this and imports above the threshold (like the beets cli would)
- strong_rec_thresh: 0.1
-
- # customize how penalties affect the match score
- distance_weights:
- # source: 2.0
- # artist: 3.0
- # album: 3.0
- # media: 1.0
- # mediums: 1.0
- # year: 1.0
- # country: 0.5
- # label: 0.5
- # catalognum: 0.5
- # albumdisambig: 0.5
- # album_id: 5.0
- # tracks: 2.0
- data_source: 0.0 # do not apply penalty to any data source plugin
- missing_tracks: 0.2 # If your prefer not being so picky about missing tracks. default 0.9
- # unmatched_tracks: 0.6
- # track_title: 3.0
- # track_artist: 2.0
- # track_index: 1.0
- # track_length: 2.0
- # track_id: 5.0
-
-musicbrainz:
- external_ids:
- discogs: yes
- bandcamp: yes
- spotify: yes
- deezer: yes
- beatport: yes
- tidal: yes
+# ------------------------------------------------------------------------------------ #
+# BEETS CONFIG #
+# ------------------------------------------------------------------------------------ #
+# Opinionated example beets configuration. This file was automatically copied to
+# /config/beets/config.yaml. Feel free to edit this file to customize the beets
+# configuration. For more information on the beets configuration, see
+# https://beets.readthedocs.io/en/stable/reference/config.html
+
+plugins: [
+ info,
+ the,
+ fetchart,
+ embedart,
+ ftintitle,
+ lastgenre,
+ missing,
+ albumtypes,
+ scrub,
+ zero,
+ mbsync,
+ duplicates,
+ convert,
+ fromfilename,
+ inline,
+ edit,
+ spotify, # needs authentication https://docs.beets.io/en/latest/plugins/spotify.html
+ musicbrainz, # needs to be enabled explicitly since beets 2.4.0
+ ]
+
+directory: /music/imported
+# library: /config/beets/library.db # default location in the container
+
+import:
+ move: no
+ copy: yes
+ write: yes
+ log: /music/last_beets_imports.log
+ quiet_fallback: skip
+ detail: yes
+ duplicate_action: ask # ask|skip|merge|keep|remove
+
+ui:
+ color: yes
+
+# fix up output file paths
+replace:
+ '[\\]': ""
+ "[_]": "-"
+ "[/]": "-"
+ '^\.+': ""
+ '[\x00-\x1f]': ""
+ '[<>:"\?\*\|]': ""
+ '\.$': ""
+ '\s+$': ""
+ '^\s+': ""
+ "^-": ""
+ "’": ""
+ "′": ""
+ "″": ""
+ "‐": "-"
+
+per_disc_numbering: no
+asciify_paths: yes
+
+# adjusting the `threaded` setting of beets should currently have no effect.
+# we launch our own workers for previews and only have one
+# import worker that runs one import (file moving) at a time.
+threaded: no
+
+fetchart:
+ minwidth: 500
+ enforce_ratio: 10px # yes|no or tolerance around 1:1 ratio
+ sources: coverart filesystem itunes amazon spotify albumart fanarttv
+
+embedart:
+ auto: yes
+ ifempty: yes # whether to avoid embedding album art for files that already have art embedded.
+ remove_art_file: yes
+
+ftintitle:
+ auto: yes
+ format: (feat. {0})
+
+lastgenre: # get genres from last fm
+ auto: yes
+ count: 4
+ prefer_specific: yes # Sort genres by the most to least specific, rather than most to least popular.
+ force: yes # By default, beets will always fetch new genres, even if the files already have one
+ source: track # album|track
+ separator: "; "
+ fallback: ""
+
+match:
+ # autotagger tolerance [0, 1], default 0.04
+ # for example, 0.1 means 90% similarity required.
+ # inbox-folder autotag setting 'auto' respects this and imports above the threshold (like the beets cli would)
+ strong_rec_thresh: 0.1
+
+ # customize how penalties affect the match score
+ distance_weights:
+ # source: 2.0
+ # artist: 3.0
+ # album: 3.0
+ # media: 1.0
+ # mediums: 1.0
+ # year: 1.0
+ # country: 0.5
+ # label: 0.5
+ # catalognum: 0.5
+ # albumdisambig: 0.5
+ # album_id: 5.0
+ # tracks: 2.0
+ data_source: 0.0 # do not apply penalty to any data source plugin
+ missing_tracks: 0.2 # If your prefer not being so picky about missing tracks. default 0.9
+ # unmatched_tracks: 0.6
+ # track_title: 3.0
+ # track_artist: 2.0
+ # track_index: 1.0
+ # track_length: 2.0
+ # track_id: 5.0
+
+musicbrainz:
+ external_ids:
+ discogs: yes
+ bandcamp: yes
+ spotify: yes
+ deezer: yes
+ beatport: yes
+ tidal: yes
diff --git a/backend/beets_flask/config/config_bf_default.yaml b/backend/beets_flask/config/config_bf_default.yaml
index aae84399..c64ad687 100644
--- a/backend/beets_flask/config/config_bf_default.yaml
+++ b/backend/beets_flask/config/config_bf_default.yaml
@@ -1,22 +1,22 @@
-# ------------------------------------------------------------------------------------ #
-# DO NOT EDIT THIS FILE #
-# ------------------------------------------------------------------------------------ #
-# these are the defaults for the gui.
-# you must provide your own config and map it to /config/beets-flask/config.yaml
-# to get started, see the auto-generated examples in /config/beets-flask/
-
-gui:
- num_preview_workers: 4
-
- library:
- readonly: no
- artist_separators: [",", ";", "&"]
-
- terminal:
- start_path: "/repo"
-
- inbox:
- ignore: "_use_beets_ignore"
- debounce_before_autotag: 30
- concat_nested_folders: yes
- expand_files: no
+# ------------------------------------------------------------------------------------ #
+# DO NOT EDIT THIS FILE #
+# ------------------------------------------------------------------------------------ #
+# these are the defaults for the gui.
+# you must provide your own config and map it to /config/beets-flask/config.yaml
+# to get started, see the auto-generated examples in /config/beets-flask/
+
+gui:
+ num_preview_workers: 4
+
+ library:
+ readonly: no
+ artist_separators: [",", ";", "&"]
+
+ terminal:
+ start_path: "/repo"
+
+ inbox:
+ ignore: "_use_beets_ignore"
+ debounce_before_autotag: 30
+ concat_nested_folders: yes
+ expand_files: no
diff --git a/backend/beets_flask/config/config_bf_example.yaml b/backend/beets_flask/config/config_bf_example.yaml
index be92d1ac..5e3bb3bd 100644
--- a/backend/beets_flask/config/config_bf_example.yaml
+++ b/backend/beets_flask/config/config_bf_example.yaml
@@ -1,45 +1,45 @@
-# ------------------------------------------------------------------------------------ #
-# BEETS GUI CONFIG #
-# ------------------------------------------------------------------------------------ #
-# Example file, this file was automatically copied to /config/beets-flask/config.yaml.
-# Feel free to edit this file to customize the gui configuration.
-# Especially the `folders` section in the `inbox` section are important to set up your inbox
-# folders. You can add as many folders as you like, but don't forget to volume-map them in
-# your docker-compose.yml.
-
-gui:
- num_preview_workers: 4 # how many previews to generate in parallel
-
- library:
- # Use to split artists in the library view if using multiple artists in a field.
- # Set to an empty list to disable this feature.
- artist_separators: [",", ";", "&"]
-
- terminal:
- start_path: "/music/inbox" # the directory where to start new terminal sessions
-
- inbox:
- folders:
- # --------------------------------- README -------------------------------- #
- # Before using the inbox feature, you need to create the folders
- # and decide on an inbox type. Have a look at the examples below.
-
- Inbox1:
- name: "Dummy inbox"
- path: "/music/dummy"
- autotag: no
- # do not automatically trigger tagging and do not automatically import
- Inbox2:
- name: "Auto Inbox"
- path: "/music/inbox_auto"
- autotag: "auto"
- # trigger tag and import if a good match is found based on `auto_threshold`
- auto_threshold: null
- # if set to null, uses the value in beets config (match.strong_rec_thresh)
- # define the distance from a perfect match, i.e. set to 0.1 to import
- # matches with 90% similarity or better.
- Inbox3:
- name: "An Inbox that only generates the previews"
- path: "/music/inbox_preview"
- autotag: "preview"
- # trigger tag but do not import, recommended for most control
+# ------------------------------------------------------------------------------------ #
+# BEETS GUI CONFIG #
+# ------------------------------------------------------------------------------------ #
+# Example file, this file was automatically copied to /config/beets-flask/config.yaml.
+# Feel free to edit this file to customize the gui configuration.
+# Especially the `folders` section in the `inbox` section are important to set up your inbox
+# folders. You can add as many folders as you like, but don't forget to volume-map them in
+# your docker-compose.yml.
+
+gui:
+ num_preview_workers: 4 # how many previews to generate in parallel
+
+ library:
+ # Use to split artists in the library view if using multiple artists in a field.
+ # Set to an empty list to disable this feature.
+ artist_separators: [",", ";", "&"]
+
+ terminal:
+ start_path: "/music/inbox" # the directory where to start new terminal sessions
+
+ inbox:
+ folders:
+ # --------------------------------- README -------------------------------- #
+ # Before using the inbox feature, you need to create the folders
+ # and decide on an inbox type. Have a look at the examples below.
+
+ Inbox1:
+ name: "Dummy inbox"
+ path: "/music/dummy"
+ autotag: no
+ # do not automatically trigger tagging and do not automatically import
+ Inbox2:
+ name: "Auto Inbox"
+ path: "/music/inbox_auto"
+ autotag: "auto"
+ # trigger tag and import if a good match is found based on `auto_threshold`
+ auto_threshold: null
+ # if set to null, uses the value in beets config (match.strong_rec_thresh)
+ # define the distance from a perfect match, i.e. set to 0.1 to import
+ # matches with 90% similarity or better.
+ Inbox3:
+ name: "An Inbox that only generates the previews"
+ path: "/music/inbox_preview"
+ autotag: "preview"
+ # trigger tag but do not import, recommended for most control
diff --git a/backend/beets_flask/config/flask_config.py b/backend/beets_flask/config/flask_config.py
index 4895525a..0a7f761c 100644
--- a/backend/beets_flask/config/flask_config.py
+++ b/backend/beets_flask/config/flask_config.py
@@ -1,122 +1,122 @@
-"""Server configuration for flask app.
-
-We have different configurations classes for
-different environments further you may create
-a custom configuration class for your own needs.
-
-The configuration classes are parse in the `create_app`
-function in the `__init__.py` file.
-"""
-
-from __future__ import annotations
-
-import os
-from collections.abc import Mapping
-
-from ..logger import log
-
-cwd = os.getcwd()
-
-
-class ServerConfig:
- DEBUG = False
- TESTING = False
-
- # If the database should be reset on start
- # Useful for development
- RESET_DB_ON_START = False
-
- # If errors should be thrown or
- # caught and logged
- # Enable for production!
- PROPAGATE_EXCEPTIONS = False
-
- # Database URI
- DATABASE_URI = "sqlite:///beets-flask-sqlite.db"
-
- # Folder where the frontend build resources are stored
- # FIXME: 2025-06-04 likely we only need this in production.
- FRONTEND_DIST_DIR = "../frontend/dist/"
-
- # Not sure if this is even used!
- SECRET_KEY = "secret"
-
- def as_dict(self) -> dict:
- return {
- "DEBUG": ServerConfig.DEBUG,
- "TESTING": ServerConfig.TESTING,
- "RESET_DB_ON_START": ServerConfig.RESET_DB_ON_START,
- "PROPAGATE_EXCEPTIONS": ServerConfig.PROPAGATE_EXCEPTIONS,
- "DATABASE_URI": ServerConfig.DATABASE_URI,
- "SECRET_KEY": ServerConfig.SECRET_KEY,
- }
-
- def __getitem__(self, key):
- return getattr(self, key)
-
-
-class Testing(ServerConfig):
- TESTING = True
- DATABASE_URI = "sqlite:///:memory:?cache=shared"
- # temporary in-memory database
- # DATABASE_URI = "sqlite://"
-
-
-class DevelopmentLocal(ServerConfig):
- RESET_DB_ON_START = True
- DEBUG = True
- DATABASE_URI = f"sqlite:///{cwd}/beets-flask-sqlite.db"
-
-
-class DevelopmentDocker(ServerConfig):
- DATABASE_URI = (
- f"sqlite:////{os.getenv('BEETSFLASKDIR')}/beets-flask-sqlite.db?timeout=5"
- )
- DEBUG = True
-
-
-# production
-class DeploymentDocker(DevelopmentDocker):
- DEBUG = False
- TESTING = False
- PROPAGATE_EXCEPTIONS = True
- FRONTEND_DIST_DIR = "/repo/frontend/dist/"
-
-
-def init_server_config(input_config: str | ServerConfig | None = None) -> ServerConfig:
- global config
-
- if isinstance(input_config, ServerConfig):
- config = input_config
- else:
- if input_config is None:
- input_config = os.environ.get("IB_SERVER_CONFIG", "dev_local")
- switch: Mapping[str, type[ServerConfig]] = {
- "dev_local": DevelopmentLocal,
- "dev_docker": DevelopmentDocker,
- "test": Testing,
- "prod": DeploymentDocker,
- }
- if isinstance(input_config, str) and input_config not in switch:
- raise ValueError(f"Invalid config: {config}")
- log.debug(f"Using config: {input_config}")
- # we still have to initalize!
- config = switch[input_config]()
-
- return config
-
-
-# if "RQ_WORKER_ID" in os.environ:
-# not elegant, but we also need to initalize the config in workers,
-# where the app init is not called
-# and for some reason it is needed in global space. revisit this in quartz port
-
-
-config: ServerConfig | None = None
-
-
-def get_flask_config() -> ServerConfig:
- global config
- if not config:
- config = init_server_config()
- return config
+"""Server configuration for flask app.
+
+We have different configurations classes for
+different environments further you may create
+a custom configuration class for your own needs.
+
+The configuration classes are parse in the `create_app`
+function in the `__init__.py` file.
+"""
+
+from __future__ import annotations
+
+import os
+from collections.abc import Mapping
+
+from ..logger import log
+
+cwd = os.getcwd()
+
+
+class ServerConfig:
+ DEBUG = False
+ TESTING = False
+
+ # If the database should be reset on start
+ # Useful for development
+ RESET_DB_ON_START = False
+
+ # If errors should be thrown or
+ # caught and logged
+ # Enable for production!
+ PROPAGATE_EXCEPTIONS = False
+
+ # Database URI
+ DATABASE_URI = "sqlite:///beets-flask-sqlite.db"
+
+ # Folder where the frontend build resources are stored
+ # FIXME: 2025-06-04 likely we only need this in production.
+ FRONTEND_DIST_DIR = "../frontend/dist/"
+
+ # Not sure if this is even used!
+ SECRET_KEY = "secret"
+
+ def as_dict(self) -> dict:
+ return {
+ "DEBUG": ServerConfig.DEBUG,
+ "TESTING": ServerConfig.TESTING,
+ "RESET_DB_ON_START": ServerConfig.RESET_DB_ON_START,
+ "PROPAGATE_EXCEPTIONS": ServerConfig.PROPAGATE_EXCEPTIONS,
+ "DATABASE_URI": ServerConfig.DATABASE_URI,
+ "SECRET_KEY": ServerConfig.SECRET_KEY,
+ }
+
+ def __getitem__(self, key):
+ return getattr(self, key)
+
+
+class Testing(ServerConfig):
+ TESTING = True
+ DATABASE_URI = "sqlite:///:memory:?cache=shared"
+ # temporary in-memory database
+ # DATABASE_URI = "sqlite://"
+
+
+class DevelopmentLocal(ServerConfig):
+ RESET_DB_ON_START = True
+ DEBUG = True
+ DATABASE_URI = f"sqlite:///{cwd}/beets-flask-sqlite.db"
+
+
+class DevelopmentDocker(ServerConfig):
+ DATABASE_URI = (
+ f"sqlite:////{os.getenv('BEETSFLASKDIR')}/beets-flask-sqlite.db?timeout=5"
+ )
+ DEBUG = True
+
+
+# production
+class DeploymentDocker(DevelopmentDocker):
+ DEBUG = False
+ TESTING = False
+ PROPAGATE_EXCEPTIONS = True
+ FRONTEND_DIST_DIR = "/repo/frontend/dist/"
+
+
+def init_server_config(input_config: str | ServerConfig | None = None) -> ServerConfig:
+ global config
+
+ if isinstance(input_config, ServerConfig):
+ config = input_config
+ else:
+ if input_config is None:
+ input_config = os.environ.get("IB_SERVER_CONFIG", "dev_local")
+ switch: Mapping[str, type[ServerConfig]] = {
+ "dev_local": DevelopmentLocal,
+ "dev_docker": DevelopmentDocker,
+ "test": Testing,
+ "prod": DeploymentDocker,
+ }
+ if isinstance(input_config, str) and input_config not in switch:
+ raise ValueError(f"Invalid config: {config}")
+ log.debug(f"Using config: {input_config}")
+ # we still have to initalize!
+ config = switch[input_config]()
+
+ return config
+
+
+# if "RQ_WORKER_ID" in os.environ:
+# not elegant, but we also need to initalize the config in workers,
+# where the app init is not called
+# and for some reason it is needed in global space. revisit this in quartz port
+
+
+config: ServerConfig | None = None
+
+
+def get_flask_config() -> ServerConfig:
+ global config
+ if not config:
+ config = init_server_config()
+ return config
diff --git a/backend/beets_flask/database/__init__.py b/backend/beets_flask/database/__init__.py
index 78d3b6a7..608a6175 100644
--- a/backend/beets_flask/database/__init__.py
+++ b/backend/beets_flask/database/__init__.py
@@ -1,7 +1,7 @@
-from .setup import db_session_factory, setup_database, with_db_session
-
-__all__ = [
- "setup_database",
- "db_session_factory",
- "with_db_session",
-]
+from .setup import db_session_factory, setup_database, with_db_session
+
+__all__ = [
+ "setup_database",
+ "db_session_factory",
+ "with_db_session",
+]
diff --git a/backend/beets_flask/database/models/__init__.py b/backend/beets_flask/database/models/__init__.py
index 0e0f4a34..c37eba94 100644
--- a/backend/beets_flask/database/models/__init__.py
+++ b/backend/beets_flask/database/models/__init__.py
@@ -1,10 +1,12 @@
-from .base import Base
-from .states import CandidateStateInDb, FolderInDb, SessionStateInDb, TaskStateInDb
-
-__all__ = [
- "Base",
- "FolderInDb",
- "SessionStateInDb",
- "TaskStateInDb",
- "CandidateStateInDb",
-]
+from .base import Base
+from .states import CandidateStateInDb, FolderInDb, SessionStateInDb, TaskStateInDb
+from .stats import CachedStatInDb
+
+__all__ = [
+ "Base",
+ "FolderInDb",
+ "SessionStateInDb",
+ "TaskStateInDb",
+ "CandidateStateInDb",
+ "CachedStatInDb",
+]
diff --git a/backend/beets_flask/database/models/base.py b/backend/beets_flask/database/models/base.py
index 02ab64fb..446a1366 100644
--- a/backend/beets_flask/database/models/base.py
+++ b/backend/beets_flask/database/models/base.py
@@ -1,120 +1,120 @@
-from __future__ import annotations
-
-from collections.abc import Mapping, Sequence
-from datetime import datetime
-from typing import Any, Self
-from uuid import uuid4
-
-import pytz
-from sqlalchemy import LargeBinary, select
-from sqlalchemy.orm import (
- DeclarativeBase,
- Mapped,
- Session,
- mapped_column,
- reconstructor,
- registry,
-)
-from sqlalchemy.sql import func
-
-from beets_flask.logger import log
-
-from .types import DictType, IntDictType, StrDictType
-
-
-class Base(DeclarativeBase):
- __abstract__ = True
-
- registry = registry(
- type_annotation_map={
- bytes: LargeBinary,
- dict[int, int]: IntDictType,
- dict[str, str]: StrDictType,
- dict[str, Any]: DictType,
- }
- )
-
- id: Mapped[str] = mapped_column(primary_key=True)
-
- created_at: Mapped[datetime] = mapped_column(default=func.now(), index=True)
- updated_at: Mapped[datetime] = mapped_column(
- default=func.now(), onupdate=func.now()
- )
-
- def __init__(self, id: str | None = None):
- self.id = str(id) if id is not None else str(uuid4())
-
- @classmethod
- def get_by(cls, *whereclause, session: Session | None = None) -> Self | None:
- close_after = False
- if session is None:
- log.debug(
- "No session provided, you will not be able to make changes to the database."
- )
- close_after = True
- from beets_flask.database.setup import session_factory
-
- session = session_factory()
-
- try:
- stmt = select(cls).where(*whereclause)
- item = session.execute(stmt).scalars().first()
- return item
- except:
- raise
- finally:
- if close_after:
- session.close()
-
- @classmethod
- def get_all_by(cls, *whereclause, session: Session | None = None) -> Sequence[Self]:
- close_after = False
- if session is None:
- log.debug(
- "No session provided, you will not be able to make changes to the database."
- )
- close_after = True
- from beets_flask.database.setup import session_factory
-
- session = session_factory()
-
- try:
- stmt = select(cls).where(*whereclause)
- item = session.execute(stmt).scalars().all()
- return item
- except:
- raise
- finally:
- if close_after:
- session.close()
-
- @classmethod
- def get_by_raise(cls, *whereclause, session: Session | None = None) -> Self:
- """Get an item by whereclause or raise ValueError if not found."""
- item = cls.get_by(*whereclause, session=session)
- if item is None:
- raise ValueError(f"{cls.__name__} not found.")
- return item
-
- @classmethod
- def exist_all_ids(cls, ids: list[str], session: Session) -> bool:
- """Check if an item exists for every given id."""
- _ids = set(ids)
- stmt = select(func.count()).select_from(cls).where(cls.id.in_(_ids))
- count = session.execute(stmt).scalar()
- if count is None or count != len(_ids):
- return False
- return True
-
- def to_dict(self) -> Mapping:
- return {c.name: getattr(self, c.name) for c in self.__table__.columns}
-
- @reconstructor
- def _sqlalchemy_reconstructor(self):
- # Set timezone info for created_at and updated_at
- # Seems a bit hacky but is the only way to ensure that
- # datetime objects are timezone-aware after deserialization
- if self.created_at and self.created_at.tzinfo is None:
- self.created_at = self.created_at.replace(tzinfo=pytz.UTC)
- if self.updated_at and self.updated_at.tzinfo is None:
- self.updated_at = self.updated_at.replace(tzinfo=pytz.UTC)
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from datetime import datetime
+from typing import Any, Self
+from uuid import uuid4
+
+import pytz
+from sqlalchemy import LargeBinary, select
+from sqlalchemy.orm import (
+ DeclarativeBase,
+ Mapped,
+ Session,
+ mapped_column,
+ reconstructor,
+ registry,
+)
+from sqlalchemy.sql import func
+
+from beets_flask.logger import log
+
+from .types import DictType, IntDictType, StrDictType
+
+
+class Base(DeclarativeBase):
+ __abstract__ = True
+
+ registry = registry(
+ type_annotation_map={
+ bytes: LargeBinary,
+ dict[int, int]: IntDictType,
+ dict[str, str]: StrDictType,
+ dict[str, Any]: DictType,
+ }
+ )
+
+ id: Mapped[str] = mapped_column(primary_key=True)
+
+ created_at: Mapped[datetime] = mapped_column(default=func.now(), index=True)
+ updated_at: Mapped[datetime] = mapped_column(
+ default=func.now(), onupdate=func.now()
+ )
+
+ def __init__(self, id: str | None = None):
+ self.id = str(id) if id is not None else str(uuid4())
+
+ @classmethod
+ def get_by(cls, *whereclause, session: Session | None = None) -> Self | None:
+ close_after = False
+ if session is None:
+ log.debug(
+ "No session provided, you will not be able to make changes to the database."
+ )
+ close_after = True
+ from beets_flask.database.setup import session_factory
+
+ session = session_factory()
+
+ try:
+ stmt = select(cls).where(*whereclause)
+ item = session.execute(stmt).scalars().first()
+ return item
+ except:
+ raise
+ finally:
+ if close_after:
+ session.close()
+
+ @classmethod
+ def get_all_by(cls, *whereclause, session: Session | None = None) -> Sequence[Self]:
+ close_after = False
+ if session is None:
+ log.debug(
+ "No session provided, you will not be able to make changes to the database."
+ )
+ close_after = True
+ from beets_flask.database.setup import session_factory
+
+ session = session_factory()
+
+ try:
+ stmt = select(cls).where(*whereclause)
+ item = session.execute(stmt).scalars().all()
+ return item
+ except:
+ raise
+ finally:
+ if close_after:
+ session.close()
+
+ @classmethod
+ def get_by_raise(cls, *whereclause, session: Session | None = None) -> Self:
+ """Get an item by whereclause or raise ValueError if not found."""
+ item = cls.get_by(*whereclause, session=session)
+ if item is None:
+ raise ValueError(f"{cls.__name__} not found.")
+ return item
+
+ @classmethod
+ def exist_all_ids(cls, ids: list[str], session: Session) -> bool:
+ """Check if an item exists for every given id."""
+ _ids = set(ids)
+ stmt = select(func.count()).select_from(cls).where(cls.id.in_(_ids))
+ count = session.execute(stmt).scalar()
+ if count is None or count != len(_ids):
+ return False
+ return True
+
+ def to_dict(self) -> Mapping:
+ return {c.name: getattr(self, c.name) for c in self.__table__.columns}
+
+ @reconstructor
+ def _sqlalchemy_reconstructor(self):
+ # Set timezone info for created_at and updated_at
+ # Seems a bit hacky but is the only way to ensure that
+ # datetime objects are timezone-aware after deserialization
+ if self.created_at and self.created_at.tzinfo is None:
+ self.created_at = self.created_at.replace(tzinfo=pytz.UTC)
+ if self.updated_at and self.updated_at.tzinfo is None:
+ self.updated_at = self.updated_at.replace(tzinfo=pytz.UTC)
diff --git a/backend/beets_flask/database/models/states.py b/backend/beets_flask/database/models/states.py
index a4798d07..eb51700b 100644
--- a/backend/beets_flask/database/models/states.py
+++ b/backend/beets_flask/database/models/states.py
@@ -1,587 +1,587 @@
-"""Minimal state model for the beets_flask application.
-
-Allows to resume a import at any time using our state dataclasses,
-see importer/state.py for more information.
-
-Why not just have State and StateInDb in the same class?
-- ORM ideally wants full mirroring of whats in RAM in the DB. This is hard to ensure
- in our case, as we dont have full control over beets tasks etc.
-- A lot of beets objects do not neatly translate to DB objects.
-- Often we want states without having to think about a DB Session.
-- Just a current motivation and choice, will revisit this later.
-"""
-
-from __future__ import annotations
-
-import io
-import pickle
-from pathlib import Path
-from typing import Any
-
-from beets.autotag import AlbumMatch
-from beets.autotag.distance import Distance
-from beets.importer import Action, ImportTask
-from beets.library.models import Item as LibraryItem
-from sqlalchemy import (
- ForeignKey,
- UniqueConstraint,
- select,
-)
-from sqlalchemy.orm import (
- Mapped,
- Session,
- mapped_column,
- relationship,
-)
-
-from beets_flask.database.models.base import Base
-from beets_flask.disk import Archive, Folder
-from beets_flask.importer.progress import Progress
-from beets_flask.importer.states import (
- CandidateState,
- SerializedCandidateState,
- SerializedSessionState,
- SerializedTaskState,
- SessionState,
- TaskState,
-)
-from beets_flask.importer.types import BeetsAlbumMatch, BeetsTrackMatch
-from beets_flask.logger import log
-from beets_flask.server.exceptions import SerializedException
-
-
-class FolderInDb(Base):
- """Represents a folder on disk, to keep track of changes.
-
- This folder does not necessarily have to exist on disk anymore. If the content
- changed, a new folder object (new hash) should be created.
- """
-
- __tablename__ = "folder"
-
- # Composite primary key
- full_path: Mapped[str] = mapped_column(index=True, primary_key=True)
-
- # checked -> yes | no or didnt check -> None
- is_album: Mapped[bool | None]
-
- def __init__(self, path: Path | str, hash: str, is_album: bool | None = None):
- """
- Create a FolderInDb object from a path.
-
- Convention:
- /home/user/foo/
- abs path with trailing slash.
-
- Parameters
- ----------
- path : Path
- The path to create the object from.
- """
- if isinstance(path, str):
- path = Path(path)
- self.full_path = str(path.resolve())
- self.hash = hash
- self.is_album = is_album
-
- @classmethod
- def from_live_folder(cls, folder: Folder | Archive) -> FolderInDb:
- """Create a FolderInDb object from a Folder object."""
- f_in_db = cls(
- path=folder.path,
- hash=folder.hash,
- )
- f_in_db.is_album = folder.is_album
-
- return f_in_db
-
- def to_live_folder(self) -> Folder:
- """Recreate the live Folder object from its stored version in the db."""
- return Folder(
- children=[],
- full_path=self.full_path,
- hash=self.hash,
- is_album=self.is_album or False,
- )
-
- def as_tuple(self) -> tuple[Path, str]:
- """Recreate the live Folder object from its stored version in the db."""
- return (
- self.path,
- self.hash,
- )
-
- @property
- def hash(self) -> str:
- """
- Convenience property to get the id.
-
- Note: Although the id is just the hash, when querying the db, you **must** use `FolderInDb.id == hash`. Sqlalchemy does not resolve properties.
- """
- return self.id
-
- @hash.setter
- def hash(self, value: str):
- self.id = value
-
- @property
- def path(self) -> Path:
- return Path(self.full_path)
-
- @classmethod
- def get_current_on_disk(cls, hash: str, path: Path | str) -> Folder | Archive:
- """
- Check that a folders hash is still the same, as you have previously determined.
-
- If changed, a new instance of FolderInDb is created and stored in the DB.
-
- Returns
- -------
- Folder: The live folder object on disk, with the potentially new (current) hash.
- """
- from beets_flask.database.setup import db_session_factory
-
- with db_session_factory() as db_session:
- if isinstance(path, str):
- path = Path(path)
- # Check if archive
- f_on_disk: Folder | Archive
- if path.is_dir():
- f_on_disk = Folder.from_path(path)
- else:
- f_on_disk = Archive.from_path(path)
-
- f_in_db = FolderInDb.get_by(FolderInDb.id == hash, session=db_session)
- if f_in_db is None:
- f_in_db = FolderInDb.from_live_folder(f_on_disk)
- db_session.merge(f_in_db)
- db_session.commit()
-
- if f_in_db.hash != f_on_disk.hash:
- log.debug(
- f"Hash mismatch {path=} {f_in_db.hash=} {f_on_disk.hash=}"
- + "This indicatest that the folder has changed."
- )
- return f_on_disk
-
-
-class SessionStateInDb(Base):
- """Represents an import session.
-
- Normally a session has one task but in theory and edge cases
- we could have multiple tasks per session.
-
- Beets uses sessions for the back-and-forth dialog with the user,
- where one session may have multiple tasks.
- We wrap the beets session in our SessionState to better handle its progress.
- And our SessionState has a representation in our database, the SessionStateInDb.
-
- Example:
- ```
- # Create
- s_live_state = SessionState(Path("path"))
- session = PreviewSession(s_live_state)
- s_live_state = session.run_sync()
- s_db_state = SessionStateInDb.from_live_state(s_live_state)
-
- # Search
- select(SessionStateInDb).where(TaskStateInDb.id == "some path").first()
- s_db_state = SessionStateInDb.get_by(
- ```
- """
-
- __tablename__ = "session"
-
- tasks: Mapped[list[TaskStateInDb]] = relationship(
- back_populates="session",
- # all: All operations cascade i.e. session.merge!
- # delete-orphan: Automatic deletion of tasks if not referenced
- # by a session anymore
- # See also https://docs.sqlalchemy.org/en/20/orm/cascades.html#unitofwork-cascades
- cascade="all, delete-orphan",
- )
-
- folder: Mapped[FolderInDb] = relationship()
- folder_hash: Mapped[str] = mapped_column(ForeignKey("folder.id"))
- folder_revision: Mapped[int] = mapped_column(default=0)
- __table_args__ = (
- UniqueConstraint(
- "folder_hash", "folder_revision", name="uq_folder_hash_revision"
- ),
- )
- # We have folder revisions to allow multiple sessions for the same folder hash,
- # the purpose being that we want to keep old sessions around. E.g. to not loose
- # old data when regenerating previews.
- # but at the same time, we want a soft 1:1 mapping between folder hash and session.
- # Thus, revisions are needed: the session-hash link always uses the highest revision.
-
- # FIXME: This should be a getter for the which queries the tasks
- progress: Mapped[Progress]
-
- # If an session run fails we want to store the exception
- exc: Mapped[bytes | None]
-
- def __init__(
- self,
- folder: FolderInDb,
- id: str | None = None,
- tasks: list[TaskStateInDb] = [],
- progress: Progress = Progress.NOT_STARTED,
- exc: SerializedException | None = None,
- ):
- super().__init__(id)
- self.folder = folder
- self.tasks = tasks
- self.progress = progress
- self.exc = pickle.dumps(exc) if exc else None
-
- @classmethod
- def from_live_state(cls, state: SessionState) -> SessionStateInDb:
- """Create the DB representation of a live SessionState.."""
-
- session = cls(
- folder=FolderInDb(state.folder_path, state.folder_hash),
- id=state.id,
- tasks=[TaskStateInDb.from_live_state(ts) for ts in state.task_states],
- progress=state.progress.progress,
- exc=state.exc,
- )
-
- return session
-
- @property
- def folder_path(self) -> Path:
- return self.folder.path
-
- def to_live_state(self, new_folder=True) -> SessionState:
- """Recreate the live SessionState with underlying task from its stored version in the db.
-
- HACK: new_folder param is a bit hacky, as if we do not include the children if we
- are not recomputing the folder hash. Might lead to some issues down the line.
- """
-
- if new_folder:
- s_state = SessionState(self.folder.path)
- else:
- s_state = SessionState(self.folder.to_live_folder())
-
- if s_state.folder_hash != self.folder.hash:
- log.warning(
- f"Folder hash mismatch for {self.folder.path}. "
- f"Expected {self.folder.hash} but got {s_state.folder_hash}."
- )
- s_state.id = self.id
- s_state.created_at = self.created_at
- s_state.updated_at = self.updated_at
- s_state._task_states = [task.to_live_state(s_state) for task in self.tasks]
- s_state.exc = pickle.loads(self.exc) if self.exc else None
- return s_state
-
- def to_dict(self) -> SerializedSessionState:
- return self.to_live_state(False).serialize()
-
- @classmethod
- def get_by_hash_and_path(
- cls,
- hash: str | None,
- path: Path | str | None,
- db_session: Session | None = None,
- ) -> SessionStateInDb | None:
- """
- Get a session by its hash and if this fails, try its path.
-
- If multiple matches, returns the most recent one.
- """
- from beets_flask.database import db_session_factory
-
- with db_session_factory(db_session) as db_session:
- item = None
- if hash is not None:
- query = (
- select(cls)
- .where(cls.folder_hash == hash)
- # hash+revision combos have unique constraints
- # and sessions always point to the latest / highest revision.
- .order_by(cls.folder_revision.desc())
- )
- item = db_session.execute(query).scalars().first()
- if item is None and path is not None:
- # Try to get by path
- # paths do not have revisions, always use last updated session
- query = (
- select(cls)
- .join(cls.folder)
- .where(FolderInDb.full_path == str(path))
- .order_by(cls.updated_at.desc(), cls.folder_revision.desc())
- )
- item = db_session.execute(query).scalars().first()
-
- return item
-
- @property
- def exception(self) -> SerializedException | None:
- """Returns the exception of the session if it failed."""
- return pickle.loads(self.exc) if self.exc else None
-
-
-class TaskStateInDb(Base):
- """Represents an import task.
-
- More precisely, beets uses one task per album that goes through a bunch of stages.
- We wrap the beets task in our TaskState to better handle its progress.
- And this TaskState has a representation in our database, the TaskStateInDb.
- """
-
- __tablename__ = "task"
-
- # Relationships
- session_id: Mapped[str] = mapped_column(ForeignKey("session.id"))
- session: Mapped[SessionStateInDb] = relationship(
- back_populates="tasks",
- foreign_keys=[session_id],
- )
-
- candidates: Mapped[list[CandidateStateInDb]] = relationship(
- back_populates="task",
- foreign_keys="[CandidateStateInDb.task_id]",
- cascade="all, delete-orphan",
- )
- # Set at the end of the import session
- chosen_candidate_id: Mapped[str | None] = mapped_column(ForeignKey("candidate.id"))
- chosen_candidate: Mapped[CandidateStateInDb | None] = relationship(
- back_populates="task",
- foreign_keys=[chosen_candidate_id],
- )
-
- toppath: Mapped[bytes | None]
-
- # To reconstruct the beets task we need to store a few of its attributes
- paths: Mapped[bytes]
- old_paths: Mapped[bytes | None]
- # old_paths contain original file paths, but are only set when files are moved.
- # (which breaks some deep links that before were identical to paths, but no more!)
- items: Mapped[bytes]
- choice_flag: Mapped[Action | None]
-
- # To allow for continue we need to store the current artist and album
- # TODO: REMOVE this is not needed!! We can look at the asis candidate for this!
- # E.g. frontend component to compare two candidates
- cur_artist: Mapped[str | None]
- cur_album: Mapped[str | None]
-
- progress: Mapped[Progress]
-
- def __init__(
- self,
- id: str | None = None,
- toppath: bytes | None = None,
- paths: list[bytes] = [],
- old_paths: list[bytes] | None = None,
- items: list[LibraryItem] = [],
- candidates: list[CandidateStateInDb] = [],
- chosen_candidate_id: str | None = None,
- progress: Progress = Progress.NOT_STARTED,
- choice_flag: Action | None = None,
- cur_artist: str | None = None,
- cur_album: str | None = None,
- ):
- super().__init__(id)
- self.toppath = toppath
- self.paths = pickle.dumps(paths)
- self.old_paths = pickle.dumps(old_paths) if old_paths else None
-
- for item in items:
- # Remove db from all items as it can't be pickled
- item._db = None
- item._Item__album = None
-
- self.items = pickle.dumps(items)
- self.candidates = candidates
- self.chosen_candidate_id = chosen_candidate_id
- self.progress = progress
- self.choice_flag = choice_flag
- self.cur_artist = cur_artist
- self.cur_album = cur_album
-
- @classmethod
- def from_live_state(cls, state: TaskState) -> TaskStateInDb:
- """Create the DB representation of a live TaskState."""
- if hasattr(state.task, "old_paths"):
- old_paths = state.task.old_paths
- else:
- old_paths = None
-
- task = cls(
- id=state.id,
- toppath=str(state.toppath).encode("utf-8") if state.toppath else None,
- paths=state.task.paths,
- items=state.task.items,
- candidates=[
- CandidateStateInDb.from_live_state(c) for c in state.candidate_states
- ],
- chosen_candidate_id=state.chosen_candidate_state_id,
- progress=state.progress.progress,
- choice_flag=state.task.choice_flag,
- cur_artist=state.task.cur_artist,
- cur_album=state.task.cur_album,
- old_paths=old_paths,
- )
- return task
-
- def to_live_state(self, session_state: SessionState | None = None) -> TaskState:
- """Recreate the live TaskState with underlying task from its stored version in the db."""
-
- # We just assume it is a normal import task
- beets_task = ImportTask(
- toppath=self.toppath,
- paths=pickle.loads(self.paths),
- items=pickle.loads(self.items),
- )
- beets_task.choice_flag = self.choice_flag
- beets_task.cur_artist = self.cur_artist
- beets_task.cur_album = self.cur_album
- old_paths: list[bytes] | None = (
- pickle.loads(self.old_paths) if self.old_paths else None
- )
- # TODO: Update type hints once beets is updated
- beets_task.old_paths = old_paths # type: ignore
-
- live_state = TaskState(beets_task)
- live_state.id = self.id
- live_state.created_at = self.created_at
- live_state.updated_at = self.updated_at
- live_state.candidate_states = [
- c.to_live_state(live_state) for c in self.candidates
- ]
- live_state.chosen_candidate_state_id = self.chosen_candidate_id
- live_state.progress.progress = self.progress
-
- # Set candidate of beets_task
- live_state.task.candidates = [c.match for c in live_state.candidate_states]
-
- return live_state
-
- def to_dict(self) -> SerializedTaskState:
- return self.to_live_state().serialize()
-
-
-class CandidateStateInDb(Base):
- """Represents a candidate (potential match) for an import task.
-
- Again: Beets-Candidate > CandidateState > CandidateStateInDb
- """
-
- __tablename__ = "candidate"
-
- task_id: Mapped[str] = mapped_column(ForeignKey("task.id"))
- task: Mapped[TaskStateInDb] = relationship(
- back_populates="candidates",
- foreign_keys=[task_id],
- )
-
- # Should deserialize to AlbumMatch|TrackMatch
- # ~4kb per match
- match: Mapped[bytes]
-
- # Duplicate ids (if any) (beets_id)
- duplicate_ids: Mapped[str]
-
- # association between tracks online and items on disk, from int to int
- mapping: Mapped[dict[int, int]]
-
- def __init__(
- self,
- match: BeetsAlbumMatch | BeetsTrackMatch,
- mapping: dict[int, int],
- duplicate_ids: list[str] = [],
- id: str | None = None,
- ):
- super().__init__(id)
-
- # Remove db from all items as it can't be pickled
- # FIXME: this should go into beets __getstate__ method
- # see https://github.com/beetbox/beets/pull/5641
- if isinstance(match, BeetsAlbumMatch):
- for item in match.mapping.keys():
- item._db = None
- item._Item__album = None
- for item in match.extra_items:
- item._db = None
- item._Item__album = None
-
- self.match = pickle.dumps(match)
- self.duplicate_ids = ";".join(map(str, duplicate_ids))
- self.mapping = mapping
-
- @classmethod
- def from_live_state(cls, state: CandidateState) -> CandidateStateInDb:
- """Create the DB representation of a live CandidateState."""
- return cls(
- id=state.id,
- match=state.match,
- duplicate_ids=state.duplicate_ids,
- mapping=state._mapping,
- )
-
- def to_live_state(self, task_state: TaskState | None) -> CandidateState:
- """Recreate the live CandidateState with underlying task from its stored version in the db."""
- if task_state is None:
- task_state = self.task.to_live_state()
- live_state = CandidateState(
- CustomUnpickler(io.BytesIO(self.match)).load(),
- task_state,
- mapping=self.mapping,
- )
- live_state.id = self.id
- live_state.created_at = self.created_at
- live_state.updated_at = self.updated_at
- live_state.duplicate_ids = (
- # edge case: "".split() gives ['']
- [] if len(self.duplicate_ids) == 0 else self.duplicate_ids.split(";")
- )
- return live_state
-
- def to_dict(self) -> SerializedCandidateState:
- return self.to_live_state(self.task.to_live_state()).serialize()
-
-
-# Hotfix for match unpickler to resolve beets distance moved
-# This is needed because various beets updates changed class implementations
-# and we want to rebuild the newer versions of some beets classes from old pickles.
-# TODO: We should fix this in general and not pickle beets objects
-class CustomUnpickler(pickle.Unpickler):
- def find_class(self, module, name):
- """Override the find_class method to redirect Distance class references."""
- # Redirect Distance class from beets.autotag.hooks to beets.distance (2.4.0)
- if module == "beets.autotag.hooks" and name == "Distance":
- return Distance
-
- # For all other classes, use the default lookup mechanism
- return super().find_class(module, name)
-
- def load(self) -> Any:
- object = super().load()
- if isinstance(object, Distance):
- self.patch_distance(object)
-
- if isinstance(object, AlbumMatch):
- self.patch_distance(object.distance)
-
- return object
-
- def patch_distance(self, distance: Distance) -> Distance:
- # Rewrite "source" penalty to "data_source" penalty (2.5.0)
- if "source" in distance._penalties:
- log.debug(
- "Converting old distance.source to distance.data_source (changed in beets 2.5.0)"
- )
- distance._penalties["data_source"] = distance._penalties["source"]
- del distance._penalties["source"]
-
- # Potential infinite recursion, ah well
- for track, d in distance.tracks.items():
- self.patch_distance(d)
- return distance
-
-
-__all__ = ["SessionStateInDb", "TaskStateInDb", "CandidateStateInDb"]
+"""Minimal state model for the beets_flask application.
+
+Allows to resume a import at any time using our state dataclasses,
+see importer/state.py for more information.
+
+Why not just have State and StateInDb in the same class?
+- ORM ideally wants full mirroring of whats in RAM in the DB. This is hard to ensure
+ in our case, as we dont have full control over beets tasks etc.
+- A lot of beets objects do not neatly translate to DB objects.
+- Often we want states without having to think about a DB Session.
+- Just a current motivation and choice, will revisit this later.
+"""
+
+from __future__ import annotations
+
+import io
+import pickle
+from pathlib import Path
+from typing import Any
+
+from beets.autotag import AlbumMatch
+from beets.autotag.distance import Distance
+from beets.importer import Action, ImportTask
+from beets.library.models import Item as LibraryItem
+from sqlalchemy import (
+ ForeignKey,
+ UniqueConstraint,
+ select,
+)
+from sqlalchemy.orm import (
+ Mapped,
+ Session,
+ mapped_column,
+ relationship,
+)
+
+from beets_flask.database.models.base import Base
+from beets_flask.disk import Archive, Folder
+from beets_flask.importer.progress import Progress
+from beets_flask.importer.states import (
+ CandidateState,
+ SerializedCandidateState,
+ SerializedSessionState,
+ SerializedTaskState,
+ SessionState,
+ TaskState,
+)
+from beets_flask.importer.types import BeetsAlbumMatch, BeetsTrackMatch
+from beets_flask.logger import log
+from beets_flask.server.exceptions import SerializedException
+
+
+class FolderInDb(Base):
+ """Represents a folder on disk, to keep track of changes.
+
+ This folder does not necessarily have to exist on disk anymore. If the content
+ changed, a new folder object (new hash) should be created.
+ """
+
+ __tablename__ = "folder"
+
+ # Composite primary key
+ full_path: Mapped[str] = mapped_column(index=True, primary_key=True)
+
+ # checked -> yes | no or didnt check -> None
+ is_album: Mapped[bool | None]
+
+ def __init__(self, path: Path | str, hash: str, is_album: bool | None = None):
+ """
+ Create a FolderInDb object from a path.
+
+ Convention:
+ /home/user/foo/
+ abs path with trailing slash.
+
+ Parameters
+ ----------
+ path : Path
+ The path to create the object from.
+ """
+ if isinstance(path, str):
+ path = Path(path)
+ self.full_path = str(path.resolve())
+ self.hash = hash
+ self.is_album = is_album
+
+ @classmethod
+ def from_live_folder(cls, folder: Folder | Archive) -> FolderInDb:
+ """Create a FolderInDb object from a Folder object."""
+ f_in_db = cls(
+ path=folder.path,
+ hash=folder.hash,
+ )
+ f_in_db.is_album = folder.is_album
+
+ return f_in_db
+
+ def to_live_folder(self) -> Folder:
+ """Recreate the live Folder object from its stored version in the db."""
+ return Folder(
+ children=[],
+ full_path=self.full_path,
+ hash=self.hash,
+ is_album=self.is_album or False,
+ )
+
+ def as_tuple(self) -> tuple[Path, str]:
+ """Recreate the live Folder object from its stored version in the db."""
+ return (
+ self.path,
+ self.hash,
+ )
+
+ @property
+ def hash(self) -> str:
+ """
+ Convenience property to get the id.
+
+ Note: Although the id is just the hash, when querying the db, you **must** use `FolderInDb.id == hash`. Sqlalchemy does not resolve properties.
+ """
+ return self.id
+
+ @hash.setter
+ def hash(self, value: str):
+ self.id = value
+
+ @property
+ def path(self) -> Path:
+ return Path(self.full_path)
+
+ @classmethod
+ def get_current_on_disk(cls, hash: str, path: Path | str) -> Folder | Archive:
+ """
+ Check that a folders hash is still the same, as you have previously determined.
+
+ If changed, a new instance of FolderInDb is created and stored in the DB.
+
+ Returns
+ -------
+ Folder: The live folder object on disk, with the potentially new (current) hash.
+ """
+ from beets_flask.database.setup import db_session_factory
+
+ with db_session_factory() as db_session:
+ if isinstance(path, str):
+ path = Path(path)
+ # Check if archive
+ f_on_disk: Folder | Archive
+ if path.is_dir():
+ f_on_disk = Folder.from_path(path)
+ else:
+ f_on_disk = Archive.from_path(path)
+
+ f_in_db = FolderInDb.get_by(FolderInDb.id == hash, session=db_session)
+ if f_in_db is None:
+ f_in_db = FolderInDb.from_live_folder(f_on_disk)
+ db_session.merge(f_in_db)
+ db_session.commit()
+
+ if f_in_db.hash != f_on_disk.hash:
+ log.debug(
+ f"Hash mismatch {path=} {f_in_db.hash=} {f_on_disk.hash=}"
+ + "This indicatest that the folder has changed."
+ )
+ return f_on_disk
+
+
+class SessionStateInDb(Base):
+ """Represents an import session.
+
+ Normally a session has one task but in theory and edge cases
+ we could have multiple tasks per session.
+
+ Beets uses sessions for the back-and-forth dialog with the user,
+ where one session may have multiple tasks.
+ We wrap the beets session in our SessionState to better handle its progress.
+ And our SessionState has a representation in our database, the SessionStateInDb.
+
+ Example:
+ ```
+ # Create
+ s_live_state = SessionState(Path("path"))
+ session = PreviewSession(s_live_state)
+ s_live_state = session.run_sync()
+ s_db_state = SessionStateInDb.from_live_state(s_live_state)
+
+ # Search
+ select(SessionStateInDb).where(TaskStateInDb.id == "some path").first()
+ s_db_state = SessionStateInDb.get_by(
+ ```
+ """
+
+ __tablename__ = "session"
+
+ tasks: Mapped[list[TaskStateInDb]] = relationship(
+ back_populates="session",
+ # all: All operations cascade i.e. session.merge!
+ # delete-orphan: Automatic deletion of tasks if not referenced
+ # by a session anymore
+ # See also https://docs.sqlalchemy.org/en/20/orm/cascades.html#unitofwork-cascades
+ cascade="all, delete-orphan",
+ )
+
+ folder: Mapped[FolderInDb] = relationship()
+ folder_hash: Mapped[str] = mapped_column(ForeignKey("folder.id"))
+ folder_revision: Mapped[int] = mapped_column(default=0)
+ __table_args__ = (
+ UniqueConstraint(
+ "folder_hash", "folder_revision", name="uq_folder_hash_revision"
+ ),
+ )
+ # We have folder revisions to allow multiple sessions for the same folder hash,
+ # the purpose being that we want to keep old sessions around. E.g. to not loose
+ # old data when regenerating previews.
+ # but at the same time, we want a soft 1:1 mapping between folder hash and session.
+ # Thus, revisions are needed: the session-hash link always uses the highest revision.
+
+ # FIXME: This should be a getter for the which queries the tasks
+ progress: Mapped[Progress]
+
+ # If an session run fails we want to store the exception
+ exc: Mapped[bytes | None]
+
+ def __init__(
+ self,
+ folder: FolderInDb,
+ id: str | None = None,
+ tasks: list[TaskStateInDb] = [],
+ progress: Progress = Progress.NOT_STARTED,
+ exc: SerializedException | None = None,
+ ):
+ super().__init__(id)
+ self.folder = folder
+ self.tasks = tasks
+ self.progress = progress
+ self.exc = pickle.dumps(exc) if exc else None
+
+ @classmethod
+ def from_live_state(cls, state: SessionState) -> SessionStateInDb:
+ """Create the DB representation of a live SessionState.."""
+
+ session = cls(
+ folder=FolderInDb(state.folder_path, state.folder_hash),
+ id=state.id,
+ tasks=[TaskStateInDb.from_live_state(ts) for ts in state.task_states],
+ progress=state.progress.progress,
+ exc=state.exc,
+ )
+
+ return session
+
+ @property
+ def folder_path(self) -> Path:
+ return self.folder.path
+
+ def to_live_state(self, new_folder=True) -> SessionState:
+ """Recreate the live SessionState with underlying task from its stored version in the db.
+
+ HACK: new_folder param is a bit hacky, as if we do not include the children if we
+ are not recomputing the folder hash. Might lead to some issues down the line.
+ """
+
+ if new_folder:
+ s_state = SessionState(self.folder.path)
+ else:
+ s_state = SessionState(self.folder.to_live_folder())
+
+ if s_state.folder_hash != self.folder.hash:
+ log.warning(
+ f"Folder hash mismatch for {self.folder.path}. "
+ f"Expected {self.folder.hash} but got {s_state.folder_hash}."
+ )
+ s_state.id = self.id
+ s_state.created_at = self.created_at
+ s_state.updated_at = self.updated_at
+ s_state._task_states = [task.to_live_state(s_state) for task in self.tasks]
+ s_state.exc = pickle.loads(self.exc) if self.exc else None
+ return s_state
+
+ def to_dict(self) -> SerializedSessionState:
+ return self.to_live_state(False).serialize()
+
+ @classmethod
+ def get_by_hash_and_path(
+ cls,
+ hash: str | None,
+ path: Path | str | None,
+ db_session: Session | None = None,
+ ) -> SessionStateInDb | None:
+ """
+ Get a session by its hash and if this fails, try its path.
+
+ If multiple matches, returns the most recent one.
+ """
+ from beets_flask.database import db_session_factory
+
+ with db_session_factory(db_session) as db_session:
+ item = None
+ if hash is not None:
+ query = (
+ select(cls)
+ .where(cls.folder_hash == hash)
+ # hash+revision combos have unique constraints
+ # and sessions always point to the latest / highest revision.
+ .order_by(cls.folder_revision.desc())
+ )
+ item = db_session.execute(query).scalars().first()
+ if item is None and path is not None:
+ # Try to get by path
+ # paths do not have revisions, always use last updated session
+ query = (
+ select(cls)
+ .join(cls.folder)
+ .where(FolderInDb.full_path == str(path))
+ .order_by(cls.updated_at.desc(), cls.folder_revision.desc())
+ )
+ item = db_session.execute(query).scalars().first()
+
+ return item
+
+ @property
+ def exception(self) -> SerializedException | None:
+ """Returns the exception of the session if it failed."""
+ return pickle.loads(self.exc) if self.exc else None
+
+
+class TaskStateInDb(Base):
+ """Represents an import task.
+
+ More precisely, beets uses one task per album that goes through a bunch of stages.
+ We wrap the beets task in our TaskState to better handle its progress.
+ And this TaskState has a representation in our database, the TaskStateInDb.
+ """
+
+ __tablename__ = "task"
+
+ # Relationships
+ session_id: Mapped[str] = mapped_column(ForeignKey("session.id"))
+ session: Mapped[SessionStateInDb] = relationship(
+ back_populates="tasks",
+ foreign_keys=[session_id],
+ )
+
+ candidates: Mapped[list[CandidateStateInDb]] = relationship(
+ back_populates="task",
+ foreign_keys="[CandidateStateInDb.task_id]",
+ cascade="all, delete-orphan",
+ )
+ # Set at the end of the import session
+ chosen_candidate_id: Mapped[str | None] = mapped_column(ForeignKey("candidate.id"))
+ chosen_candidate: Mapped[CandidateStateInDb | None] = relationship(
+ back_populates="task",
+ foreign_keys=[chosen_candidate_id],
+ )
+
+ toppath: Mapped[bytes | None]
+
+ # To reconstruct the beets task we need to store a few of its attributes
+ paths: Mapped[bytes]
+ old_paths: Mapped[bytes | None]
+ # old_paths contain original file paths, but are only set when files are moved.
+ # (which breaks some deep links that before were identical to paths, but no more!)
+ items: Mapped[bytes]
+ choice_flag: Mapped[Action | None]
+
+ # To allow for continue we need to store the current artist and album
+ # TODO: REMOVE this is not needed!! We can look at the asis candidate for this!
+ # E.g. frontend component to compare two candidates
+ cur_artist: Mapped[str | None]
+ cur_album: Mapped[str | None]
+
+ progress: Mapped[Progress]
+
+ def __init__(
+ self,
+ id: str | None = None,
+ toppath: bytes | None = None,
+ paths: list[bytes] = [],
+ old_paths: list[bytes] | None = None,
+ items: list[LibraryItem] = [],
+ candidates: list[CandidateStateInDb] = [],
+ chosen_candidate_id: str | None = None,
+ progress: Progress = Progress.NOT_STARTED,
+ choice_flag: Action | None = None,
+ cur_artist: str | None = None,
+ cur_album: str | None = None,
+ ):
+ super().__init__(id)
+ self.toppath = toppath
+ self.paths = pickle.dumps(paths)
+ self.old_paths = pickle.dumps(old_paths) if old_paths else None
+
+ for item in items:
+ # Remove db from all items as it can't be pickled
+ item._db = None
+ item._Item__album = None
+
+ self.items = pickle.dumps(items)
+ self.candidates = candidates
+ self.chosen_candidate_id = chosen_candidate_id
+ self.progress = progress
+ self.choice_flag = choice_flag
+ self.cur_artist = cur_artist
+ self.cur_album = cur_album
+
+ @classmethod
+ def from_live_state(cls, state: TaskState) -> TaskStateInDb:
+ """Create the DB representation of a live TaskState."""
+ if hasattr(state.task, "old_paths"):
+ old_paths = state.task.old_paths
+ else:
+ old_paths = None
+
+ task = cls(
+ id=state.id,
+ toppath=str(state.toppath).encode("utf-8") if state.toppath else None,
+ paths=state.task.paths,
+ items=state.task.items,
+ candidates=[
+ CandidateStateInDb.from_live_state(c) for c in state.candidate_states
+ ],
+ chosen_candidate_id=state.chosen_candidate_state_id,
+ progress=state.progress.progress,
+ choice_flag=state.task.choice_flag,
+ cur_artist=state.task.cur_artist,
+ cur_album=state.task.cur_album,
+ old_paths=old_paths,
+ )
+ return task
+
+ def to_live_state(self, session_state: SessionState | None = None) -> TaskState:
+ """Recreate the live TaskState with underlying task from its stored version in the db."""
+
+ # We just assume it is a normal import task
+ beets_task = ImportTask(
+ toppath=self.toppath,
+ paths=pickle.loads(self.paths),
+ items=pickle.loads(self.items),
+ )
+ beets_task.choice_flag = self.choice_flag
+ beets_task.cur_artist = self.cur_artist
+ beets_task.cur_album = self.cur_album
+ old_paths: list[bytes] | None = (
+ pickle.loads(self.old_paths) if self.old_paths else None
+ )
+ # TODO: Update type hints once beets is updated
+ beets_task.old_paths = old_paths # type: ignore
+
+ live_state = TaskState(beets_task)
+ live_state.id = self.id
+ live_state.created_at = self.created_at
+ live_state.updated_at = self.updated_at
+ live_state.candidate_states = [
+ c.to_live_state(live_state) for c in self.candidates
+ ]
+ live_state.chosen_candidate_state_id = self.chosen_candidate_id
+ live_state.progress.progress = self.progress
+
+ # Set candidate of beets_task
+ live_state.task.candidates = [c.match for c in live_state.candidate_states]
+
+ return live_state
+
+ def to_dict(self) -> SerializedTaskState:
+ return self.to_live_state().serialize()
+
+
+class CandidateStateInDb(Base):
+ """Represents a candidate (potential match) for an import task.
+
+ Again: Beets-Candidate > CandidateState > CandidateStateInDb
+ """
+
+ __tablename__ = "candidate"
+
+ task_id: Mapped[str] = mapped_column(ForeignKey("task.id"))
+ task: Mapped[TaskStateInDb] = relationship(
+ back_populates="candidates",
+ foreign_keys=[task_id],
+ )
+
+ # Should deserialize to AlbumMatch|TrackMatch
+ # ~4kb per match
+ match: Mapped[bytes]
+
+ # Duplicate ids (if any) (beets_id)
+ duplicate_ids: Mapped[str]
+
+ # association between tracks online and items on disk, from int to int
+ mapping: Mapped[dict[int, int]]
+
+ def __init__(
+ self,
+ match: BeetsAlbumMatch | BeetsTrackMatch,
+ mapping: dict[int, int],
+ duplicate_ids: list[str] = [],
+ id: str | None = None,
+ ):
+ super().__init__(id)
+
+ # Remove db from all items as it can't be pickled
+ # FIXME: this should go into beets __getstate__ method
+ # see https://github.com/beetbox/beets/pull/5641
+ if isinstance(match, BeetsAlbumMatch):
+ for item in match.mapping.keys():
+ item._db = None
+ item._Item__album = None
+ for item in match.extra_items:
+ item._db = None
+ item._Item__album = None
+
+ self.match = pickle.dumps(match)
+ self.duplicate_ids = ";".join(map(str, duplicate_ids))
+ self.mapping = mapping
+
+ @classmethod
+ def from_live_state(cls, state: CandidateState) -> CandidateStateInDb:
+ """Create the DB representation of a live CandidateState."""
+ return cls(
+ id=state.id,
+ match=state.match,
+ duplicate_ids=state.duplicate_ids,
+ mapping=state._mapping,
+ )
+
+ def to_live_state(self, task_state: TaskState | None) -> CandidateState:
+ """Recreate the live CandidateState with underlying task from its stored version in the db."""
+ if task_state is None:
+ task_state = self.task.to_live_state()
+ live_state = CandidateState(
+ CustomUnpickler(io.BytesIO(self.match)).load(),
+ task_state,
+ mapping=self.mapping,
+ )
+ live_state.id = self.id
+ live_state.created_at = self.created_at
+ live_state.updated_at = self.updated_at
+ live_state.duplicate_ids = (
+ # edge case: "".split() gives ['']
+ [] if len(self.duplicate_ids) == 0 else self.duplicate_ids.split(";")
+ )
+ return live_state
+
+ def to_dict(self) -> SerializedCandidateState:
+ return self.to_live_state(self.task.to_live_state()).serialize()
+
+
+# Hotfix for match unpickler to resolve beets distance moved
+# This is needed because various beets updates changed class implementations
+# and we want to rebuild the newer versions of some beets classes from old pickles.
+# TODO: We should fix this in general and not pickle beets objects
+class CustomUnpickler(pickle.Unpickler):
+ def find_class(self, module, name):
+ """Override the find_class method to redirect Distance class references."""
+ # Redirect Distance class from beets.autotag.hooks to beets.distance (2.4.0)
+ if module == "beets.autotag.hooks" and name == "Distance":
+ return Distance
+
+ # For all other classes, use the default lookup mechanism
+ return super().find_class(module, name)
+
+ def load(self) -> Any:
+ object = super().load()
+ if isinstance(object, Distance):
+ self.patch_distance(object)
+
+ if isinstance(object, AlbumMatch):
+ self.patch_distance(object.distance)
+
+ return object
+
+ def patch_distance(self, distance: Distance) -> Distance:
+ # Rewrite "source" penalty to "data_source" penalty (2.5.0)
+ if "source" in distance._penalties:
+ log.debug(
+ "Converting old distance.source to distance.data_source (changed in beets 2.5.0)"
+ )
+ distance._penalties["data_source"] = distance._penalties["source"]
+ del distance._penalties["source"]
+
+ # Potential infinite recursion, ah well
+ for track, d in distance.tracks.items():
+ self.patch_distance(d)
+ return distance
+
+
+__all__ = ["SessionStateInDb", "TaskStateInDb", "CandidateStateInDb"]
diff --git a/backend/beets_flask/database/models/stats.py b/backend/beets_flask/database/models/stats.py
new file mode 100644
index 00000000..31e47457
--- /dev/null
+++ b/backend/beets_flask/database/models/stats.py
@@ -0,0 +1,51 @@
+"""Cached filesystem statistics model.
+
+Stores pre-computed dir size and file counts so that the stats endpoints
+(home page) can return instantly without running expensive subprocesses.
+
+Values are written by:
+- The watchdog after its debounce fires (inbox stats)
+- The import workers after an import completes (library stats)
+- On watchdog startup for each configured inbox
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.sql import func
+
+from beets_flask.database.models.base import Base
+
+
+class CachedStatInDb(Base):
+ """Cached filesystem stats for a single directory path.
+
+ The ``id`` (inherited primary key) is set to the resolved absolute path
+ string so each path maps to exactly one row.
+ """
+
+ __tablename__ = "cached_stats"
+
+ # Size of the directory tree in bytes (du -sb)
+ size_bytes: Mapped[int | None] = mapped_column(default=None)
+ # Total number of files/dirs under the path (find | wc -l)
+ n_files: Mapped[int | None] = mapped_column(default=None)
+ # When these values were last computed
+ computed_at: Mapped[float] = mapped_column(default=func.now())
+
+ def __init__(
+ self,
+ path: Path | str,
+ size_bytes: int | None = None,
+ n_files: int | None = None,
+ ):
+ path = Path(path)
+ super().__init__(id=str(path.resolve()))
+ self.size_bytes = size_bytes
+ self.n_files = n_files
+
+ @property
+ def path(self) -> Path:
+ return Path(self.id)
diff --git a/backend/beets_flask/database/models/types.py b/backend/beets_flask/database/models/types.py
index 4d558a60..ad108efa 100644
--- a/backend/beets_flask/database/models/types.py
+++ b/backend/beets_flask/database/models/types.py
@@ -1,67 +1,67 @@
-import json
-from typing import Any
-
-from sqlalchemy import types
-
-
-class DictType(types.TypeDecorator):
- """Stores a dict[str, Any] as a JSON-encoded string in the database.
-
- Allows for flexible storage of dictionaries with string keys and values of
- any (serializable) type.
- """
-
- impl = types.Text
- cache_ok = True
-
- allowed_keys_types: tuple[type, ...] = (str,)
- allowed_values_types: tuple[type | Any, ...] = (Any,)
-
- def process_bind_param(self, value, dialect):
- if value is None:
- return None
- if not isinstance(value, dict):
- raise ValueError("Value must be a dict")
-
- # Any type needs some special handling
- allowed_types_v: tuple[type, ...] = tuple(
- filter(lambda x: x is not Any, self.allowed_values_types)
- )
-
- if not len(allowed_types_v) == 0:
- if not all(isinstance(v, allowed_types_v) for v in value.values()):
- raise ValueError(
- f"Value must be a dict with values of type {allowed_types_v}. Got: {value.values()}"
- )
-
- if not all(isinstance(k, self.allowed_keys_types) for k in value.keys()):
- raise ValueError(f"Keys must be of type {self.allowed_keys_types}.")
-
- return json.dumps({str(k): v for k, v in value.items()})
-
- def process_result_value(self, value, dialect):
- if value is None:
- return None
- return json.loads(value)
-
- def copy(self, **kw):
- return self.__class__(self.impl.length) # type: ignore
-
-
-class IntDictType(DictType):
- """Stores a dict[int, int] as a JSON-encoded string in the database."""
-
- allowed_keys_types = (int,)
- allowed_values_types = (int,)
-
- def process_result_value(self, value, dialect):
- if value is None:
- return None
- return {int(k): int(v) for k, v in json.loads(value).items()}
-
-
-class StrDictType(DictType):
- """Stores a dict[str, str] as a JSON-encoded string in the database."""
-
- allowed_keys_types = (str,)
- allowed_values_types = (str,)
+import json
+from typing import Any
+
+from sqlalchemy import types
+
+
+class DictType(types.TypeDecorator):
+ """Stores a dict[str, Any] as a JSON-encoded string in the database.
+
+ Allows for flexible storage of dictionaries with string keys and values of
+ any (serializable) type.
+ """
+
+ impl = types.Text
+ cache_ok = True
+
+ allowed_keys_types: tuple[type, ...] = (str,)
+ allowed_values_types: tuple[type | Any, ...] = (Any,)
+
+ def process_bind_param(self, value, dialect):
+ if value is None:
+ return None
+ if not isinstance(value, dict):
+ raise ValueError("Value must be a dict")
+
+ # Any type needs some special handling
+ allowed_types_v: tuple[type, ...] = tuple(
+ filter(lambda x: x is not Any, self.allowed_values_types)
+ )
+
+ if not len(allowed_types_v) == 0:
+ if not all(isinstance(v, allowed_types_v) for v in value.values()):
+ raise ValueError(
+ f"Value must be a dict with values of type {allowed_types_v}. Got: {value.values()}"
+ )
+
+ if not all(isinstance(k, self.allowed_keys_types) for k in value.keys()):
+ raise ValueError(f"Keys must be of type {self.allowed_keys_types}.")
+
+ return json.dumps({str(k): v for k, v in value.items()})
+
+ def process_result_value(self, value, dialect):
+ if value is None:
+ return None
+ return json.loads(value)
+
+ def copy(self, **kw):
+ return self.__class__(self.impl.length) # type: ignore
+
+
+class IntDictType(DictType):
+ """Stores a dict[int, int] as a JSON-encoded string in the database."""
+
+ allowed_keys_types = (int,)
+ allowed_values_types = (int,)
+
+ def process_result_value(self, value, dialect):
+ if value is None:
+ return None
+ return {int(k): int(v) for k, v in json.loads(value).items()}
+
+
+class StrDictType(DictType):
+ """Stores a dict[str, str] as a JSON-encoded string in the database."""
+
+ allowed_keys_types = (str,)
+ allowed_values_types = (str,)
diff --git a/backend/beets_flask/database/setup.py b/backend/beets_flask/database/setup.py
index 09a3bb63..fc771cf1 100644
--- a/backend/beets_flask/database/setup.py
+++ b/backend/beets_flask/database/setup.py
@@ -1,128 +1,128 @@
-from contextlib import contextmanager
-from functools import wraps
-
-from quart import Quart
-from sqlalchemy import Engine, create_engine
-from sqlalchemy.orm import Session, scoped_session, sessionmaker
-
-from beets_flask.config import get_flask_config
-from beets_flask.logger import log
-
-from .models import Base
-
-engine: Engine | None = None
-session_factory: scoped_session[Session]
-
-
-def setup_database(app: Quart | None = None) -> None:
- """Set up the database connection and session factory for the FLask application.
-
- This function initializes the global `engine` and `session_factory` variables
- using the database URI specified in the application's configuration. It also
- sets up a teardown hook to gracefully close the database session when the
- application context is torn down.
-
- Args:
- app (Quart): The Quart application instance.
-
- Returns
- -------
- None
- """
- __setup_factory()
- if get_flask_config()["RESET_DB_ON_START"]:
- log.warning("Resetting database due to RESET_DB=True in config")
- _reset_database()
-
- _create_tables(engine)
-
- if app is not None:
- # Gracefully shutdown the database session, if launched
- # from within a Flask app context.
- @app.teardown_appcontext
- def shutdown_session(exception=None) -> None:
- session_factory.remove()
-
-
-def __setup_factory():
- global engine
- global session_factory
-
- engine = create_engine(get_flask_config()["DATABASE_URI"])
- session_factory = scoped_session(sessionmaker(bind=engine, expire_on_commit=False))
-
-
-@contextmanager
-def db_session_factory(session: Session | None = None):
- """Databases session as context.
-
- Makes sure sessions are closed at the end.
- If an existing session is provided, it will not be closed at the end.
- This allows to wrap multiple `with db_session()` blocks around each other without closing the outer session.
-
- Example:
- ```
- with db_session() as session:
- tag.foo = "bar"
- session.merge(tag)
- return tag.to_dict()
-
- existingSession = session_factory()
- with db_session(session) as s:
- tag.foo = "bar"
- s.merge(tag)
- return tag.to_dict()
- ```
- """
- is_outermost = session is None
- if is_outermost:
- try:
- session = session_factory()
- except NameError:
- __setup_factory()
- session = session_factory()
-
- try:
- # mypy does not resolve our try/catch for None-Type check. ignore type errors.``
- yield session
- session.commit() # type: ignore
- except:
- session.rollback() # type: ignore
- raise
- finally:
- if is_outermost:
- session.close() # type: ignore
-
-
-def with_db_session(func):
- """Decorate a function with a db session as a keyword argument to the function.
-
- Example
- ```
- @with_db_session
- def my_function(session=None):
- tag.foo = "bar"
- session.merge(tag)
- return tag.to_dict()
- ```
- """
-
- @wraps(func)
- def wrapper(*args, **kwargs):
- with db_session_factory() as session:
- kwargs.setdefault("session", session)
- return func(*args, **kwargs)
-
- return wrapper
-
-
-def _create_tables(engine) -> None:
- Base.metadata.create_all(bind=engine)
-
-
-def _reset_database():
- # Removes all data from the database but keeps schema
- for t in reversed(Base.metadata.sorted_tables):
- with db_session_factory() as session:
- session.execute(t.delete())
- session.commit()
+from contextlib import contextmanager
+from functools import wraps
+
+from quart import Quart
+from sqlalchemy import Engine, create_engine
+from sqlalchemy.orm import Session, scoped_session, sessionmaker
+
+from beets_flask.config import get_flask_config
+from beets_flask.logger import log
+
+from .models import Base
+
+engine: Engine | None = None
+session_factory: scoped_session[Session]
+
+
+def setup_database(app: Quart | None = None) -> None:
+ """Set up the database connection and session factory for the FLask application.
+
+ This function initializes the global `engine` and `session_factory` variables
+ using the database URI specified in the application's configuration. It also
+ sets up a teardown hook to gracefully close the database session when the
+ application context is torn down.
+
+ Args:
+ app (Quart): The Quart application instance.
+
+ Returns
+ -------
+ None
+ """
+ __setup_factory()
+ if get_flask_config()["RESET_DB_ON_START"]:
+ log.warning("Resetting database due to RESET_DB=True in config")
+ _reset_database()
+
+ _create_tables(engine)
+
+ if app is not None:
+ # Gracefully shutdown the database session, if launched
+ # from within a Flask app context.
+ @app.teardown_appcontext
+ def shutdown_session(exception=None) -> None:
+ session_factory.remove()
+
+
+def __setup_factory():
+ global engine
+ global session_factory
+
+ engine = create_engine(get_flask_config()["DATABASE_URI"])
+ session_factory = scoped_session(sessionmaker(bind=engine, expire_on_commit=False))
+
+
+@contextmanager
+def db_session_factory(session: Session | None = None):
+ """Databases session as context.
+
+ Makes sure sessions are closed at the end.
+ If an existing session is provided, it will not be closed at the end.
+ This allows to wrap multiple `with db_session()` blocks around each other without closing the outer session.
+
+ Example:
+ ```
+ with db_session() as session:
+ tag.foo = "bar"
+ session.merge(tag)
+ return tag.to_dict()
+
+ existingSession = session_factory()
+ with db_session(session) as s:
+ tag.foo = "bar"
+ s.merge(tag)
+ return tag.to_dict()
+ ```
+ """
+ is_outermost = session is None
+ if is_outermost:
+ try:
+ session = session_factory()
+ except NameError:
+ __setup_factory()
+ session = session_factory()
+
+ try:
+ # mypy does not resolve our try/catch for None-Type check. ignore type errors.``
+ yield session
+ session.commit() # type: ignore
+ except:
+ session.rollback() # type: ignore
+ raise
+ finally:
+ if is_outermost:
+ session.close() # type: ignore
+
+
+def with_db_session(func):
+ """Decorate a function with a db session as a keyword argument to the function.
+
+ Example
+ ```
+ @with_db_session
+ def my_function(session=None):
+ tag.foo = "bar"
+ session.merge(tag)
+ return tag.to_dict()
+ ```
+ """
+
+ @wraps(func)
+ def wrapper(*args, **kwargs):
+ with db_session_factory() as session:
+ kwargs.setdefault("session", session)
+ return func(*args, **kwargs)
+
+ return wrapper
+
+
+def _create_tables(engine) -> None:
+ Base.metadata.create_all(bind=engine)
+
+
+def _reset_database():
+ # Removes all data from the database but keeps schema
+ for t in reversed(Base.metadata.sorted_tables):
+ with db_session_factory() as session:
+ session.execute(t.delete())
+ session.commit()
diff --git a/backend/beets_flask/dirhash_custom.py b/backend/beets_flask/dirhash_custom.py
index 3555b496..006f6a7a 100644
--- a/backend/beets_flask/dirhash_custom.py
+++ b/backend/beets_flask/dirhash_custom.py
@@ -1,92 +1,92 @@
-import os
-from hashlib import md5
-from pathlib import Path
-from re import Pattern
-
-from cachetools import Cache
-
-
-def dirhash_c(
- dirname: str | Path,
- cache: Cache[str, bytes] | None,
- filter_regex: Pattern[str] | None = None,
-) -> bytes:
- """Compute a hash for a directory.
-
- The hash is computed by hashing the path of each file and using
- its filesystem metadata (size, mtime, ctime).
-
- Parameters
- ----------
- dirname: str
- The path to the directory
- cache: dict, optional
- A cache object to store intermediate results. If None, no caching is used.
- filter_regex: re.Pattern, optional
- When calculating checksum contributon for files, only consider
- those that match the provided pattern.
- """
- if isinstance(dirname, Path):
- dirname = str(dirname.resolve())
-
- if cache is not None and dirname in cache:
- return cache[dirname]
-
- hash = md5()
-
- # Hash for each entry in the directory
- for entry in os.scandir(dirname):
- if entry.is_dir():
- hash.update(dirhash_c(entry.path, cache, filter_regex))
- else:
- # Skip files that do not match the filter
- if filter_regex is not None and not filter_regex.match(entry.name):
- continue
-
- fs = os.stat(entry.path)
- hash.update(fs.st_size.to_bytes(8, byteorder="big"))
- hash.update(fs.st_ino.to_bytes(8, byteorder="big"))
- hash.update(str(fs.st_mtime).encode())
- hash.update(entry.name.encode())
-
- # dirstats
- fs = os.stat(dirname)
- hash.update(dirname.encode())
- # hash.update(fs.st_size.to_bytes(8, byteorder="big"))
- # hash.update(fs.st_ino.to_bytes(8, byteorder="big"))
- # hash.update(str(fs.st_mtime).encode())
-
- # for dirs we should use very little info.
- # For instance, mtime, ino, size get changed when e.g. a file
- # is added directly inside - and this becomes somewhat inconsistent with
- # regex ignore patterns. (adding an ignored file would still modify the hash)
-
- return hash.digest()
-
-
-def archive_hash(
- f_path: str | Path,
- cache: Cache[str, bytes] | None = None,
-) -> bytes:
- """Compute a hash for an archive file."""
-
- if isinstance(f_path, Path):
- f_path = str(f_path.resolve())
-
- if cache is not None and f_path in cache:
- return cache[f_path]
-
- hash = md5()
- fs = os.stat(f_path)
- hash.update(fs.st_size.to_bytes(8, byteorder="big"))
- hash.update(fs.st_ino.to_bytes(8, byteorder="big"))
- hash.update(str(fs.st_mtime).encode())
- hash.update(os.path.basename(f_path).encode())
-
- if cache is not None:
- cache[f_path] = hash.digest()
-
- return hash.digest()
-
-
-__all__ = ["dirhash_c", "archive_hash"]
+import os
+from hashlib import md5
+from pathlib import Path
+from re import Pattern
+
+from cachetools import Cache
+
+
+def dirhash_c(
+ dirname: str | Path,
+ cache: Cache[str, bytes] | None,
+ filter_regex: Pattern[str] | None = None,
+) -> bytes:
+ """Compute a hash for a directory.
+
+ The hash is computed by hashing the path of each file and using
+ its filesystem metadata (size, mtime, ctime).
+
+ Parameters
+ ----------
+ dirname: str
+ The path to the directory
+ cache: dict, optional
+ A cache object to store intermediate results. If None, no caching is used.
+ filter_regex: re.Pattern, optional
+ When calculating checksum contributon for files, only consider
+ those that match the provided pattern.
+ """
+ if isinstance(dirname, Path):
+ dirname = str(dirname.resolve())
+
+ if cache is not None and dirname in cache:
+ return cache[dirname]
+
+ hash = md5()
+
+ # Hash for each entry in the directory
+ for entry in os.scandir(dirname):
+ if entry.is_dir():
+ hash.update(dirhash_c(entry.path, cache, filter_regex))
+ else:
+ # Skip files that do not match the filter
+ if filter_regex is not None and not filter_regex.match(entry.name):
+ continue
+
+ fs = os.stat(entry.path)
+ hash.update(fs.st_size.to_bytes(8, byteorder="big"))
+ hash.update(fs.st_ino.to_bytes(8, byteorder="big"))
+ hash.update(str(fs.st_mtime).encode())
+ hash.update(entry.name.encode())
+
+ # dirstats
+ fs = os.stat(dirname)
+ hash.update(dirname.encode())
+ # hash.update(fs.st_size.to_bytes(8, byteorder="big"))
+ # hash.update(fs.st_ino.to_bytes(8, byteorder="big"))
+ # hash.update(str(fs.st_mtime).encode())
+
+ # for dirs we should use very little info.
+ # For instance, mtime, ino, size get changed when e.g. a file
+ # is added directly inside - and this becomes somewhat inconsistent with
+ # regex ignore patterns. (adding an ignored file would still modify the hash)
+
+ return hash.digest()
+
+
+def archive_hash(
+ f_path: str | Path,
+ cache: Cache[str, bytes] | None = None,
+) -> bytes:
+ """Compute a hash for an archive file."""
+
+ if isinstance(f_path, Path):
+ f_path = str(f_path.resolve())
+
+ if cache is not None and f_path in cache:
+ return cache[f_path]
+
+ hash = md5()
+ fs = os.stat(f_path)
+ hash.update(fs.st_size.to_bytes(8, byteorder="big"))
+ hash.update(fs.st_ino.to_bytes(8, byteorder="big"))
+ hash.update(str(fs.st_mtime).encode())
+ hash.update(os.path.basename(f_path).encode())
+
+ if cache is not None:
+ cache[f_path] = hash.digest()
+
+ return hash.digest()
+
+
+__all__ = ["dirhash_c", "archive_hash"]
diff --git a/backend/beets_flask/disk.py b/backend/beets_flask/disk.py
index 3e71e31f..fcaaf815 100644
--- a/backend/beets_flask/disk.py
+++ b/backend/beets_flask/disk.py
@@ -1,474 +1,546 @@
-from __future__ import annotations
-
-import os
-import re
-import subprocess
-from abc import ABC, abstractmethod
-from collections.abc import Iterator, Sequence
-from dataclasses import dataclass
-from fnmatch import fnmatch
-from pathlib import Path
-from typing import (
- Literal,
-)
-
-from beets.importer import (
- ArchiveImportTask,
-)
-from beets.importer.tasks import (
- MULTIDISC_MARKERS,
- MULTIDISC_PAT_FMT,
- albums_in_dir,
-)
-from beets.util import bytestring_path
-from cachetools import Cache, TTLCache, cached
-from natsort import os_sorted
-
-from beets_flask.config import get_config
-from beets_flask.dirhash_custom import archive_hash, dirhash_c
-from beets_flask.logger import log
-from beets_flask.utility import AUDIO_EXTENSIONS
-
-# Regex pattern to exclude hidden files (files starting with ".")
-audio_regex = re.compile(
- r".*\.(" + "|".join(AUDIO_EXTENSIONS) + ")$",
- re.IGNORECASE,
-)
-
-
-@dataclass
-class FileSystemItem(ABC):
- """Base class for file system items."""
-
- type: Literal["file", "directory", "archive"]
- full_path: str
- hash: str
-
- # If beets has marked this folder as an album, or, if its a file, archives can be
- # imported. Singletons (importing music files directly) is not supported yet.
- is_album: bool
-
- @property
- def path(self) -> Path:
- # For convenience, to get `full_path` as a Path object,
- # but in the frontend and sqlite database, we use strings (full_path).
- return Path(self.full_path)
-
- @path.setter
- def path(self, value: Path) -> None:
- self.full_path = str(value)
-
- @classmethod
- @abstractmethod
- def from_path(
- cls, path: Path | str, cache: Cache[str, bytes] | None = None
- ) -> FileSystemItem:
- """Create a FileSystemItem object from a path."""
- raise NotImplementedError("This method should be implemented in subclasses.")
-
-
-def fs_item_from_path(
- path: Path | str, cache: Cache[str, bytes] | None = None, subdirs: bool = True
-) -> File | Folder | Archive:
- """Create a _specific_ FileSystemItem from a path."""
- if isinstance(path, str):
- path = Path(path)
-
- if path.is_dir():
- return Folder.from_path(path, cache=cache, subdirs=subdirs)
- elif is_archive_file(path):
- return Archive.from_path(path, cache=cache)
- else:
- return File.from_path(path, cache=cache)
-
-
-def _matches_patterns(s: str, patterns: list[str]) -> bool:
- """Check if a string matches any of the given patterns."""
- return any(fnmatch(s, pat) for pat in patterns)
-
-
-@dataclass
-class Folder(FileSystemItem):
- children: Sequence[FileSystemItem]
-
- def __init__(
- self,
- children: Sequence[FileSystemItem],
- full_path: str,
- hash: str,
- is_album: bool = False,
- ):
- super().__init__(
- full_path=full_path,
- hash=hash,
- is_album=is_album,
- type="directory",
- )
- self.children = children
-
- @classmethod
- def from_path(
- cls,
- path: Path | str,
- cache: Cache[str, bytes] | None = None,
- subdirs=True,
- ) -> Folder:
- """Create a Folder object from a path."""
-
- ignore_globs = get_config().ignore_globs
-
- if isinstance(path, str):
- path = Path(path)
-
- if not path.is_dir():
- raise FileNotFoundError(f"Path `{path}` does not exist or is no directory.")
-
- path = path.resolve()
-
- album_folders = all_album_folders(path, subdirs=subdirs)
-
- # Cache for the dirhash function
- if cache is None:
- cache = Cache(maxsize=2**16)
-
- # Object that contains all tree elements, because
- # we need to fill top down but iterate buttom up
- lookup: dict[str, Folder] = dict()
-
- # Iterate over all directories from bottom to top
- for dirpath, dirnames, filenames in os.walk(path, topdown=False):
- if _matches_patterns(os.path.basename(dirpath), ignore_globs):
- continue
-
- # Skip ignored files
- # TODO: I think we could optimize this by
- # compiling to regex
- _dirnames = filter(
- lambda d: not _matches_patterns(d, ignore_globs), dirnames
- )
- _filenames = filter(
- lambda f: not _matches_patterns(f, ignore_globs), filenames
- )
-
- # As we iterate from bottom to top, we can access the elements from
- # the lookup table as they are already created
- children: list[FileSystemItem] = [
- lookup[os.path.join(dirpath, sub_dir)]
- for sub_dir in os_sorted(_dirnames)
- ]
-
- # Add all files to children
- for filename in os_sorted(_filenames):
- full_path = os.path.join(dirpath, filename)
- # Here, we know this not a folder, so we can use fs_item_from_path.
- children.append(
- fs_item_from_path(path=os.path.abspath(full_path), cache=cache)
- )
-
- # Add current directory to lookup
- lookup[dirpath] = Folder(
- children=children,
- full_path=os.path.abspath(dirpath),
- hash=dirhash_c(
- dirpath,
- cache,
- filter_regex=audio_regex, # Only hash audio files
- ).hex(),
- is_album=Path(dirpath) in album_folders,
- )
-
- return lookup[str(path)]
-
- def walk(self) -> Iterator[FileSystemItem]:
- """Walk the folder and yield all files and folders."""
- yield self
- for child in self.children:
- if isinstance(child, Folder):
- yield from child.walk()
- else:
- yield child
-
-
-@dataclass
-class Archive(FileSystemItem):
- # Defaults to true, as we assume that we can import an archive as an album
- is_album: bool = True
-
- def __init__(self, full_path: str, hash: str):
- super().__init__(
- full_path=full_path,
- hash=hash,
- is_album=True, # Archives are always considered albums
- type="archive",
- )
-
- @classmethod
- def from_path(
- cls, path: Path | str, cache: Cache[str, bytes] | None = None
- ) -> Archive:
- """Create an Archive object from a path."""
- if isinstance(path, str):
- path = Path(path)
-
- if not is_archive_file(path):
- raise FileNotFoundError(f"Path `{path}` is not an archive file.")
-
- if cache is None:
- cache = Cache(maxsize=2**16)
-
- return cls(
- full_path=str(path.resolve()),
- hash=archive_hash(path, cache=cache).hex(),
- )
-
-
-def is_archive_file(path: Path | str) -> bool:
- """Check if a file is an archive file based on its extension."""
- return ArchiveImportTask.is_archive(str(path))
-
-
-@dataclass
-class File(FileSystemItem):
- def __init__(self, full_path: str):
- super().__init__(
- full_path=full_path,
- hash="", # Files do not have a hash atm (maybe later we can add a hash)
- is_album=False, # Files are not considered albums
- type="file",
- )
-
- @classmethod
- def from_path(
- cls, path: Path | str, cache: Cache[str, bytes] | None = None
- ) -> File:
- """Create a File object from a path."""
- if isinstance(path, str):
- path = Path(path)
-
- if not path.is_file():
- raise FileNotFoundError(f"Path `{path}` is not a file.")
-
- full_path = str(path.resolve())
-
- return cls(
- full_path=full_path,
- )
-
-
-@cached(cache=TTLCache(maxsize=1024, ttl=900), info=True)
-def path_to_folder(root_dir: Path | str, subdirs=True) -> Folder:
- """Generate our nested dict structure for the specified path.
-
- Parameters
- ----------
- root_dir : str
- The root directory to start from.
- subdirs : bool, optional
- Whether to mark qualifying subfolders of an album as album folders themselves. If true, e.g. for `/album/CD1/track.mp3` both `/album/` and `/album/CD1/` are flagged. Defaults to True.
-
- Returns
- -------
- dict: The nested dict structure.
- """
-
- return Folder.from_path(root_dir, subdirs=subdirs)
-
-
-def album_folders_from_track_paths(
- track_paths: list[Path] | list[str], use_parent_for_multidisc: bool = True
-) -> list[Path]:
- """Get all album folders from a list of paths to files.
-
- Parameters
- ----------
- track_paths : list[Path]
- list of track paths, e.g. mp3 files.
- use_parent_for_multidisc : bool, optional
- When files are in an album folder that might be a multi-disc folder (e.g. `/album/cd1`),
- return the parent (`/album`) instead of the lowest-level-folder (`/cd1`). Defaults to True.
-
- Returns
- -------
- list[str]: album folders
- """
-
- folders_to_check: set[Path] = set()
- album_folders: set[Path] = set()
- for path in track_paths:
- # FIXME: For backwards compatibility, we allow a string as input
- if isinstance(path, str):
- path = Path(path)
-
- if is_archive_file(path):
- album_folders.add(path.resolve())
- elif path.is_file():
- folders_to_check.add(path.parent.resolve())
- else:
- # just to be nice and manage directories instead of files
- folders_to_check.add(path.resolve())
-
- for folder in folders_to_check:
- afs = all_album_folders(folder, subdirs=True)
- for af in afs:
- album_folders.add(af)
-
- if use_parent_for_multidisc:
- parents: set[Path] = set()
- children: set[Path] = set()
- for folder in album_folders:
- if is_within_multi_dir(folder):
- parents.add(folder.parent)
- children.add(folder)
-
- album_folders = album_folders - children
- album_folders = album_folders.union(parents)
-
- return sorted(album_folders, key=lambda s: str(s).lower())
-
-
-def is_album_folder(path: Path | str):
- """Check if a path is an album folder.
-
- Returns true if the path is detected as an album by beets, or if it is an archive file.
- -------
- path : Path | str
- The path to check, can be a folder, file or archive.
-
- Note
- ----
- Except in tests, we dont use this function yet.
- Its logic is duplicated in `all_album_folders`. (We should consolidate.)
- """
- if isinstance(path, str):
- path = Path(path).absolute()
- if is_archive_file(path):
- return True
- for paths, items in albums_in_dir(bytestring_path(path)):
- if all(is_archive_file(i.decode("utf-8")) for i in items):
- continue
- if str(path).encode("utf-8") in paths:
- return True
- return False
-
-
-def all_album_folders(root_dir: Path | str, subdirs: bool = False) -> list[Path]:
- """
- Get all album folders from a given root dir.
-
- Parameters
- ----------
- root_dir : str
- toppath, highest level to start searching.
- subdirs : bool, optional
- Whether to return subfolders of an album that themselves would qualify.
- E.g. a `CD1` folder. Defaults to False.
-
- Returns
- -------
- list[Path]
- """
-
- # FIXME: For backwards compatibility, we allow a string as input
- if isinstance(root_dir, str):
- root_dir = Path(root_dir)
-
- folders: list[bytes] = []
- for paths, items in albums_in_dir(bytestring_path(root_dir.absolute())):
- # Our choice on handling archives:
- # - archives are always simple albums. no multi-disc logic supported,
- # all discs need to be _inside_ the archive.
- # - if a folder contains only archives, it will never be considered an
- # album folder
- # - if a folder contains a mix of archives and music files, it will be
- # considered an album folder (as we think archives might be metadata or additional files e.g. cover art)
-
- if all(is_archive_file(i.decode("utf-8")) for i in items):
- folders.extend(items)
- continue
-
- if subdirs:
- folders.extend(p for p in paths)
- else:
- # the top-level path is always the first in the list
- # however, there is an edgecase, if we have a rogue element in a multi-disc folder:
- # - artist/album/should_not_be_here.mp3
- # - artist/album/CD1/track.mp3
- # - artist/album/CD2/track.mp3
- # -> then albums_in_dir returns [album], [CD1, CD2] so that picking the first element is wrong.
- # we would want all 3: album, CD1 and CD2. but in this case, the parent `album` should already
- # be in our set when we check [CD1, CD2]
- if os.path.dirname(paths[0]) in folders:
- folders.extend(p for p in paths)
- else:
- folders.append(paths[0])
-
- return [Path(f.decode("utf-8")) for f in folders]
-
-
-def is_within_multi_dir(path: Path | str) -> bool:
- """
- Minimal version of beets heuristic to check if a string matches a multi-disc pattern.
-
- E.g. "My Album CD1" or "Disc 2" will return True
- """
-
- # FIXME: For backwards compatibility, we allow a string as input
- if isinstance(path, str):
- path = Path(path)
-
- path_str = path.name # Use pathlib to get the basename
-
- for marker in MULTIDISC_MARKERS:
- p = MULTIDISC_PAT_FMT.replace(b"%s", marker)
- marker_pat = re.compile(p, re.I)
- match = marker_pat.match(path_str.encode("utf-8"))
- if match:
- return True
- return False
-
-
-@cached(cache=TTLCache(maxsize=1024, ttl=60), info=True)
-def dir_size(path: Path) -> int:
- """Size of a dir in bytes, including content."""
- try:
- result = subprocess.run(
- ["du", "-sb", str(path.resolve())],
- capture_output=True,
- text=True,
- check=True,
- )
- size = int(result.stdout.split()[0])
- return size
- except Exception as e:
- # this happens e.g. if the directory does not exist.
- log.error(e)
- return -1
-
-
-@cached(cache=TTLCache(maxsize=1024, ttl=60), info=True)
-def dir_files(path: Path) -> int:
- """Count the number of files in a directory."""
- try:
- result = subprocess.run(
- [f"find {str(path.resolve())} | wc -l"],
- capture_output=True,
- text=True,
- check=True,
- shell=True,
- )
- count = int(result.stdout)
- return count
- except Exception as e:
- # this happens e.g. if the directory does not exist.
- log.error(e)
- return -1
-
-
-def clear_cache():
- """Clear the cache for all cached functions."""
- path_to_folder.cache.clear() # type: ignore
- dir_size.cache.clear() # type: ignore
- dir_files.cache.clear() # type: ignore
-
-
-__all__ = ["dir_size", "fs_item_from_path"]
+from __future__ import annotations
+
+import asyncio
+import os
+import re
+import subprocess
+from abc import ABC, abstractmethod
+from collections.abc import Iterator, Sequence
+from dataclasses import dataclass
+from fnmatch import fnmatch
+from pathlib import Path
+from typing import (
+ TYPE_CHECKING,
+ Literal,
+)
+
+if TYPE_CHECKING:
+ from beets_flask.database.models.stats import CachedStatInDb
+
+from beets.importer import (
+ ArchiveImportTask,
+)
+from beets.importer.tasks import (
+ MULTIDISC_MARKERS,
+ MULTIDISC_PAT_FMT,
+ albums_in_dir,
+)
+from beets.util import bytestring_path
+from cachetools import Cache, TTLCache, cached
+from natsort import os_sorted
+
+from beets_flask.config import get_config
+from beets_flask.dirhash_custom import archive_hash, dirhash_c
+from beets_flask.logger import log
+from beets_flask.utility import AUDIO_EXTENSIONS
+
+# Regex pattern to exclude hidden files (files starting with ".")
+audio_regex = re.compile(
+ r".*\.(" + "|".join(AUDIO_EXTENSIONS) + ")$",
+ re.IGNORECASE,
+)
+
+
+@dataclass
+class FileSystemItem(ABC):
+ """Base class for file system items."""
+
+ type: Literal["file", "directory", "archive"]
+ full_path: str
+ hash: str
+
+ # If beets has marked this folder as an album, or, if its a file, archives can be
+ # imported. Singletons (importing music files directly) is not supported yet.
+ is_album: bool
+
+ @property
+ def path(self) -> Path:
+ # For convenience, to get `full_path` as a Path object,
+ # but in the frontend and sqlite database, we use strings (full_path).
+ return Path(self.full_path)
+
+ @path.setter
+ def path(self, value: Path) -> None:
+ self.full_path = str(value)
+
+ @classmethod
+ @abstractmethod
+ def from_path(
+ cls, path: Path | str, cache: Cache[str, bytes] | None = None
+ ) -> FileSystemItem:
+ """Create a FileSystemItem object from a path."""
+ raise NotImplementedError("This method should be implemented in subclasses.")
+
+
+def fs_item_from_path(
+ path: Path | str, cache: Cache[str, bytes] | None = None, subdirs: bool = True
+) -> File | Folder | Archive:
+ """Create a _specific_ FileSystemItem from a path."""
+ if isinstance(path, str):
+ path = Path(path)
+
+ if path.is_dir():
+ return Folder.from_path(path, cache=cache, subdirs=subdirs)
+ elif is_archive_file(path):
+ return Archive.from_path(path, cache=cache)
+ else:
+ return File.from_path(path, cache=cache)
+
+
+def _matches_patterns(s: str, patterns: list[str]) -> bool:
+ """Check if a string matches any of the given patterns."""
+ return any(fnmatch(s, pat) for pat in patterns)
+
+
+@dataclass
+class Folder(FileSystemItem):
+ children: Sequence[FileSystemItem]
+
+ def __init__(
+ self,
+ children: Sequence[FileSystemItem],
+ full_path: str,
+ hash: str,
+ is_album: bool = False,
+ ):
+ super().__init__(
+ full_path=full_path,
+ hash=hash,
+ is_album=is_album,
+ type="directory",
+ )
+ self.children = children
+
+ @classmethod
+ def from_path(
+ cls,
+ path: Path | str,
+ cache: Cache[str, bytes] | None = None,
+ subdirs=True,
+ ) -> Folder:
+ """Create a Folder object from a path."""
+
+ ignore_globs = get_config().ignore_globs
+
+ if isinstance(path, str):
+ path = Path(path)
+
+ if not path.is_dir():
+ raise FileNotFoundError(f"Path `{path}` does not exist or is no directory.")
+
+ path = path.resolve()
+
+ album_folders = all_album_folders(path, subdirs=subdirs)
+
+ # Cache for the dirhash function
+ if cache is None:
+ cache = Cache(maxsize=2**16)
+
+ # Object that contains all tree elements, because
+ # we need to fill top down but iterate buttom up
+ lookup: dict[str, Folder] = dict()
+
+ # Iterate over all directories from bottom to top
+ for dirpath, dirnames, filenames in os.walk(path, topdown=False):
+ if _matches_patterns(os.path.basename(dirpath), ignore_globs):
+ continue
+
+ # Skip ignored files
+ # TODO: I think we could optimize this by
+ # compiling to regex
+ _dirnames = filter(
+ lambda d: not _matches_patterns(d, ignore_globs), dirnames
+ )
+ _filenames = filter(
+ lambda f: not _matches_patterns(f, ignore_globs), filenames
+ )
+
+ # As we iterate from bottom to top, we can access the elements from
+ # the lookup table as they are already created
+ children: list[FileSystemItem] = [
+ lookup[os.path.join(dirpath, sub_dir)]
+ for sub_dir in os_sorted(_dirnames)
+ ]
+
+ # Add all files to children
+ for filename in os_sorted(_filenames):
+ full_path = os.path.join(dirpath, filename)
+ # Here, we know this not a folder, so we can use fs_item_from_path.
+ children.append(
+ fs_item_from_path(path=os.path.abspath(full_path), cache=cache)
+ )
+
+ # Add current directory to lookup
+ lookup[dirpath] = Folder(
+ children=children,
+ full_path=os.path.abspath(dirpath),
+ hash=dirhash_c(
+ dirpath,
+ cache,
+ filter_regex=audio_regex, # Only hash audio files
+ ).hex(),
+ is_album=Path(dirpath) in album_folders,
+ )
+
+ return lookup[str(path)]
+
+ def walk(self) -> Iterator[FileSystemItem]:
+ """Walk the folder and yield all files and folders."""
+ yield self
+ for child in self.children:
+ if isinstance(child, Folder):
+ yield from child.walk()
+ else:
+ yield child
+
+
+@dataclass
+class Archive(FileSystemItem):
+ # Defaults to true, as we assume that we can import an archive as an album
+ is_album: bool = True
+
+ def __init__(self, full_path: str, hash: str):
+ super().__init__(
+ full_path=full_path,
+ hash=hash,
+ is_album=True, # Archives are always considered albums
+ type="archive",
+ )
+
+ @classmethod
+ def from_path(
+ cls, path: Path | str, cache: Cache[str, bytes] | None = None
+ ) -> Archive:
+ """Create an Archive object from a path."""
+ if isinstance(path, str):
+ path = Path(path)
+
+ if not is_archive_file(path):
+ raise FileNotFoundError(f"Path `{path}` is not an archive file.")
+
+ if cache is None:
+ cache = Cache(maxsize=2**16)
+
+ return cls(
+ full_path=str(path.resolve()),
+ hash=archive_hash(path, cache=cache).hex(),
+ )
+
+
+def is_archive_file(path: Path | str) -> bool:
+ """Check if a file is an archive file based on its extension."""
+ return ArchiveImportTask.is_archive(str(path))
+
+
+@dataclass
+class File(FileSystemItem):
+ def __init__(self, full_path: str):
+ super().__init__(
+ full_path=full_path,
+ hash="", # Files do not have a hash atm (maybe later we can add a hash)
+ is_album=False, # Files are not considered albums
+ type="file",
+ )
+
+ @classmethod
+ def from_path(
+ cls, path: Path | str, cache: Cache[str, bytes] | None = None
+ ) -> File:
+ """Create a File object from a path."""
+ if isinstance(path, str):
+ path = Path(path)
+
+ if not path.is_file():
+ raise FileNotFoundError(f"Path `{path}` is not a file.")
+
+ full_path = str(path.resolve())
+
+ return cls(
+ full_path=full_path,
+ )
+
+
+@cached(cache=TTLCache(maxsize=1024, ttl=900), info=True)
+def path_to_folder(root_dir: Path | str, subdirs=True) -> Folder:
+ """Generate our nested dict structure for the specified path.
+
+ Parameters
+ ----------
+ root_dir : str
+ The root directory to start from.
+ subdirs : bool, optional
+ Whether to mark qualifying subfolders of an album as album folders themselves. If true, e.g. for `/album/CD1/track.mp3` both `/album/` and `/album/CD1/` are flagged. Defaults to True.
+
+ Returns
+ -------
+ dict: The nested dict structure.
+ """
+
+ return Folder.from_path(root_dir, subdirs=subdirs)
+
+
+def album_folders_from_track_paths(
+ track_paths: list[Path] | list[str], use_parent_for_multidisc: bool = True
+) -> list[Path]:
+ """Get all album folders from a list of paths to files.
+
+ Parameters
+ ----------
+ track_paths : list[Path]
+ list of track paths, e.g. mp3 files.
+ use_parent_for_multidisc : bool, optional
+ When files are in an album folder that might be a multi-disc folder (e.g. `/album/cd1`),
+ return the parent (`/album`) instead of the lowest-level-folder (`/cd1`). Defaults to True.
+
+ Returns
+ -------
+ list[str]: album folders
+ """
+
+ folders_to_check: set[Path] = set()
+ album_folders: set[Path] = set()
+ for path in track_paths:
+ # FIXME: For backwards compatibility, we allow a string as input
+ if isinstance(path, str):
+ path = Path(path)
+
+ if is_archive_file(path):
+ album_folders.add(path.resolve())
+ elif path.is_file():
+ folders_to_check.add(path.parent.resolve())
+ else:
+ # just to be nice and manage directories instead of files
+ folders_to_check.add(path.resolve())
+
+ for folder in folders_to_check:
+ afs = all_album_folders(folder, subdirs=True)
+ for af in afs:
+ album_folders.add(af)
+
+ if use_parent_for_multidisc:
+ parents: set[Path] = set()
+ children: set[Path] = set()
+ for folder in album_folders:
+ if is_within_multi_dir(folder):
+ parents.add(folder.parent)
+ children.add(folder)
+
+ album_folders = album_folders - children
+ album_folders = album_folders.union(parents)
+
+ return sorted(album_folders, key=lambda s: str(s).lower())
+
+
+def is_album_folder(path: Path | str):
+ """Check if a path is an album folder.
+
+ Returns true if the path is detected as an album by beets, or if it is an archive file.
+ -------
+ path : Path | str
+ The path to check, can be a folder, file or archive.
+
+ Note
+ ----
+ Except in tests, we dont use this function yet.
+ Its logic is duplicated in `all_album_folders`. (We should consolidate.)
+ """
+ if isinstance(path, str):
+ path = Path(path).absolute()
+ if is_archive_file(path):
+ return True
+ for paths, items in albums_in_dir(bytestring_path(path)):
+ if all(is_archive_file(i.decode("utf-8")) for i in items):
+ continue
+ if str(path).encode("utf-8") in paths:
+ return True
+ return False
+
+
+def all_album_folders(root_dir: Path | str, subdirs: bool = False) -> list[Path]:
+ """
+ Get all album folders from a given root dir.
+
+ Parameters
+ ----------
+ root_dir : str
+ toppath, highest level to start searching.
+ subdirs : bool, optional
+ Whether to return subfolders of an album that themselves would qualify.
+ E.g. a `CD1` folder. Defaults to False.
+
+ Returns
+ -------
+ list[Path]
+ """
+
+ # FIXME: For backwards compatibility, we allow a string as input
+ if isinstance(root_dir, str):
+ root_dir = Path(root_dir)
+
+ folders: list[bytes] = []
+ for paths, items in albums_in_dir(bytestring_path(root_dir.absolute())):
+ # Our choice on handling archives:
+ # - archives are always simple albums. no multi-disc logic supported,
+ # all discs need to be _inside_ the archive.
+ # - if a folder contains only archives, it will never be considered an
+ # album folder
+ # - if a folder contains a mix of archives and music files, it will be
+ # considered an album folder (as we think archives might be metadata or additional files e.g. cover art)
+
+ if all(is_archive_file(i.decode("utf-8")) for i in items):
+ folders.extend(items)
+ continue
+
+ if subdirs:
+ folders.extend(p for p in paths)
+ else:
+ # the top-level path is always the first in the list
+ # however, there is an edgecase, if we have a rogue element in a multi-disc folder:
+ # - artist/album/should_not_be_here.mp3
+ # - artist/album/CD1/track.mp3
+ # - artist/album/CD2/track.mp3
+ # -> then albums_in_dir returns [album], [CD1, CD2] so that picking the first element is wrong.
+ # we would want all 3: album, CD1 and CD2. but in this case, the parent `album` should already
+ # be in our set when we check [CD1, CD2]
+ if os.path.dirname(paths[0]) in folders:
+ folders.extend(p for p in paths)
+ else:
+ folders.append(paths[0])
+
+ return [Path(f.decode("utf-8")) for f in folders]
+
+
+def is_within_multi_dir(path: Path | str) -> bool:
+ """
+ Minimal version of beets heuristic to check if a string matches a multi-disc pattern.
+
+ E.g. "My Album CD1" or "Disc 2" will return True
+ """
+
+ # FIXME: For backwards compatibility, we allow a string as input
+ if isinstance(path, str):
+ path = Path(path)
+
+ path_str = path.name # Use pathlib to get the basename
+
+ for marker in MULTIDISC_MARKERS:
+ p = MULTIDISC_PAT_FMT.replace(b"%s", marker)
+ marker_pat = re.compile(p, re.I)
+ match = marker_pat.match(path_str.encode("utf-8"))
+ if match:
+ return True
+ return False
+
+
+def _dir_size_subprocess(path: Path) -> int:
+ """Run ``du -sb`` and return bytes. Uncached."""
+ try:
+ result = subprocess.run(
+ ["du", "-sb", str(path.resolve())],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ return int(result.stdout.split()[0])
+ except Exception as e:
+ # this happens e.g. if the directory does not exist.
+ log.error(e)
+ return -1
+
+
+def _dir_files_subprocess(path: Path) -> int:
+ """Run ``find | wc -l`` and return count. Uncached."""
+ try:
+ result = subprocess.run(
+ [f"find {str(path.resolve())} | wc -l"],
+ capture_output=True,
+ text=True,
+ check=True,
+ shell=True,
+ )
+ return int(result.stdout)
+ except Exception as e:
+ # this happens e.g. if the directory does not exist.
+ log.error(e)
+ return -1
+
+
+@cached(cache=TTLCache(maxsize=1024, ttl=60), info=True)
+def dir_size(path: Path) -> int:
+ """Size of a dir in bytes, including content.
+
+ Cached for 60 s. Prefer :func:`get_cached_dir_stats` for hot paths —
+ it reads a pre-computed value from the database instead of running a
+ subprocess on every cache miss.
+ """
+ return _dir_size_subprocess(path)
+
+
+@cached(cache=TTLCache(maxsize=1024, ttl=60), info=True)
+def dir_files(path: Path) -> int:
+ """Count the number of files in a directory.
+
+ Cached for 60 s. Prefer :func:`get_cached_dir_stats` for hot paths.
+ """
+ return _dir_files_subprocess(path)
+
+
+def clear_cache():
+ """Clear the directory-tree cache.
+
+ Only clears :func:`path_to_folder` so that the inbox tree view stays
+ current after filesystem changes. The ``dir_size`` / ``dir_files``
+ TTL caches are left alone — those are now fallbacks only; the
+ authoritative values live in the ``cached_stats`` DB table and are
+ updated by :func:`compute_and_store_dir_stats`.
+ """
+ path_to_folder.cache.clear() # type: ignore
+
+
+# ---------------------------------------------------------------------------
+# DB-backed stats helpers
+# ---------------------------------------------------------------------------
+
+
+async def compute_and_store_dir_stats(path: Path) -> CachedStatInDb:
+ """Compute dir size + file count and persist to the database.
+
+ Runs the subprocess calls in a thread so the async event loop is not
+ blocked. Call this from the watchdog (after debounce) and from import
+ workers after a successful import so that the stats endpoints always
+ return a pre-computed value.
+
+ Parameters
+ ----------
+ path:
+ Directory to measure. Will be resolved to an absolute path.
+ """
+ from beets_flask.database.models.stats import CachedStatInDb
+ from beets_flask.database.setup import db_session_factory
+
+ resolved = path.resolve()
+ size = await asyncio.to_thread(_dir_size_subprocess, resolved)
+ n_files = await asyncio.to_thread(_dir_files_subprocess, resolved)
+
+ stat = CachedStatInDb(path=resolved, size_bytes=size, n_files=n_files)
+ with db_session_factory() as session:
+ session.merge(stat)
+
+ log.info(f"Stats updated for {resolved}: {n_files} files, {size} bytes")
+ return stat
+
+
+def get_cached_dir_stats(path: Path) -> CachedStatInDb | None:
+ """Return the most recently stored stats for *path*, or ``None``."""
+ from beets_flask.database.models.stats import CachedStatInDb
+ from beets_flask.database.setup import db_session_factory
+
+ with db_session_factory() as session:
+ row = session.get(CachedStatInDb, str(path.resolve()))
+ if row is not None:
+ # Ensure attributes are loaded while the session is still open
+ _ = row.size_bytes, row.n_files, row.computed_at
+ return row
+
+
+__all__ = ["dir_size", "fs_item_from_path", "compute_and_store_dir_stats", "get_cached_dir_stats"]
diff --git a/backend/beets_flask/importer/__init__.py b/backend/beets_flask/importer/__init__.py
index 01ea95ed..65415afe 100644
--- a/backend/beets_flask/importer/__init__.py
+++ b/backend/beets_flask/importer/__init__.py
@@ -1,7 +1,7 @@
-from . import types
-from .states import SessionState
-
-__all__ = [
- "SessionState",
- "types",
-]
+from . import types
+from .states import SessionState
+
+__all__ = [
+ "SessionState",
+ "types",
+]
diff --git a/backend/beets_flask/importer/pipeline.py b/backend/beets_flask/importer/pipeline.py
index 533cea41..b401f02e 100644
--- a/backend/beets_flask/importer/pipeline.py
+++ b/backend/beets_flask/importer/pipeline.py
@@ -1,124 +1,124 @@
-"""Beets pipeline overloads.
-
-Added type hints to decorators because it is annoying to have to look up
-the type of the function usage.
-
-This is a temporary implementation. Async Generators are an antipattern
-(use coroutines instead). We only use them here because the older beets pipeline
-used Generators, and we still rely on parts of it.
-"""
-
-import asyncio
-from collections.abc import (
- AsyncGenerator,
- AsyncIterable,
- Coroutine,
- Generator,
- Iterable,
- Sequence,
-)
-from typing import (
- Any,
- Generic,
- Literal,
- TypeVar,
-)
-
-from beets.util.pipeline import MultiMessage, _allmsgs
-
-# Generics for Generators
-Y = TypeVar("Y") # yield
-S = TypeVar("S") # send
-R = TypeVar("R") # return
-
-# --------------------------------- Pipeline --------------------------------- #
-
-# yield : Task or None, send : Task, return : None
-Task = TypeVar("Task", bound=Any)
-Stage = (
- Generator[Task | MultiMessage | Literal["__PIPELINE_BUBBLE__"] | None, Task, R]
- | AsyncGenerator[Task | None, Task]
-)
-
-
-class AsyncPipeline(Generic[Task, R]):
- start_tasks: AsyncIterable[Task]
- stages: list[Stage[Task, R]]
-
- # Original: stages = [start_task, *stages]
- def __init__(
- self,
- start_tasks: Iterable[Task] | AsyncIterable[Task] | Task,
- stages: Sequence[Stage[Task, R]] = [],
- ) -> None:
- if isinstance(start_tasks, Iterable):
- self.start_tasks = _async_iterable_from_iterable(start_tasks)
- elif isinstance(start_tasks, AsyncIterable):
- self.start_tasks = start_tasks
- else:
- self.start_tasks = _async_iterable_from_iterable([start_tasks])
-
- self.stages = list(stages)
-
- def add_stage(self, *stage: Stage[Task, R]) -> None:
- """Add a stage to the pipeline."""
- for s in stage:
- self.stages.append(s)
-
- async def pull_async(self) -> AsyncGenerator[Task, None]:
- """Pull items through the pipeline.
-
- If item is coroutine, await it.
- """
- # Priming -> Wait for first send i.e. right side of `task = yield`
- for stage in self.stages:
- await _next_resolve_async(stage)
-
- async for task in self.start_tasks:
- msgs: list[Task] = _allmsgs(task) # returns a list of tasks
-
- for stage in self.stages:
- next_coros: list[Coroutine] = [
- _send_resolve_async(stage, msg) for msg in msgs
- ]
- # override for input of next stage
- msgs = []
- for out in await asyncio.gather(*next_coros):
- msgs.extend(_allmsgs(out))
-
- for msg in msgs:
- yield msg
-
- async def run_async(self) -> None:
- """Run the pipeline asynchronously.
-
- This just resolves the generator.
- """
- async for _ in self.pull_async():
- # resolves the generator, and awaits each element after the previous
- pass
-
-
-async def _next_resolve_async(gen: Generator[Y, S, R] | AsyncGenerator[Y, S]):
- """Call next on the generator."""
- if isinstance(gen, Generator):
- return next(gen)
- else:
- return await anext(gen)
-
-
-async def _send_resolve_async(
- gen: Generator[Y, S, R] | AsyncGenerator[Y, S], *args, **kwargs
-):
- """Send to the generator."""
- if isinstance(gen, Generator):
- return gen.send(*args, **kwargs)
- else:
- return await gen.asend(*args, **kwargs)
-
-
-async def _async_iterable_from_iterable(
- iterable: Iterable[Task],
-) -> AsyncIterable[Task]:
- for item in iterable:
- yield item
+"""Beets pipeline overloads.
+
+Added type hints to decorators because it is annoying to have to look up
+the type of the function usage.
+
+This is a temporary implementation. Async Generators are an antipattern
+(use coroutines instead). We only use them here because the older beets pipeline
+used Generators, and we still rely on parts of it.
+"""
+
+import asyncio
+from collections.abc import (
+ AsyncGenerator,
+ AsyncIterable,
+ Coroutine,
+ Generator,
+ Iterable,
+ Sequence,
+)
+from typing import (
+ Any,
+ Generic,
+ Literal,
+ TypeVar,
+)
+
+from beets.util.pipeline import MultiMessage, _allmsgs
+
+# Generics for Generators
+Y = TypeVar("Y") # yield
+S = TypeVar("S") # send
+R = TypeVar("R") # return
+
+# --------------------------------- Pipeline --------------------------------- #
+
+# yield : Task or None, send : Task, return : None
+Task = TypeVar("Task", bound=Any)
+Stage = (
+ Generator[Task | MultiMessage | Literal["__PIPELINE_BUBBLE__"] | None, Task, R]
+ | AsyncGenerator[Task | None, Task]
+)
+
+
+class AsyncPipeline(Generic[Task, R]):
+ start_tasks: AsyncIterable[Task]
+ stages: list[Stage[Task, R]]
+
+ # Original: stages = [start_task, *stages]
+ def __init__(
+ self,
+ start_tasks: Iterable[Task] | AsyncIterable[Task] | Task,
+ stages: Sequence[Stage[Task, R]] = [],
+ ) -> None:
+ if isinstance(start_tasks, Iterable):
+ self.start_tasks = _async_iterable_from_iterable(start_tasks)
+ elif isinstance(start_tasks, AsyncIterable):
+ self.start_tasks = start_tasks
+ else:
+ self.start_tasks = _async_iterable_from_iterable([start_tasks])
+
+ self.stages = list(stages)
+
+ def add_stage(self, *stage: Stage[Task, R]) -> None:
+ """Add a stage to the pipeline."""
+ for s in stage:
+ self.stages.append(s)
+
+ async def pull_async(self) -> AsyncGenerator[Task, None]:
+ """Pull items through the pipeline.
+
+ If item is coroutine, await it.
+ """
+ # Priming -> Wait for first send i.e. right side of `task = yield`
+ for stage in self.stages:
+ await _next_resolve_async(stage)
+
+ async for task in self.start_tasks:
+ msgs: list[Task] = _allmsgs(task) # returns a list of tasks
+
+ for stage in self.stages:
+ next_coros: list[Coroutine] = [
+ _send_resolve_async(stage, msg) for msg in msgs
+ ]
+ # override for input of next stage
+ msgs = []
+ for out in await asyncio.gather(*next_coros):
+ msgs.extend(_allmsgs(out))
+
+ for msg in msgs:
+ yield msg
+
+ async def run_async(self) -> None:
+ """Run the pipeline asynchronously.
+
+ This just resolves the generator.
+ """
+ async for _ in self.pull_async():
+ # resolves the generator, and awaits each element after the previous
+ pass
+
+
+async def _next_resolve_async(gen: Generator[Y, S, R] | AsyncGenerator[Y, S]):
+ """Call next on the generator."""
+ if isinstance(gen, Generator):
+ return next(gen)
+ else:
+ return await anext(gen)
+
+
+async def _send_resolve_async(
+ gen: Generator[Y, S, R] | AsyncGenerator[Y, S], *args, **kwargs
+):
+ """Send to the generator."""
+ if isinstance(gen, Generator):
+ return gen.send(*args, **kwargs)
+ else:
+ return await gen.asend(*args, **kwargs)
+
+
+async def _async_iterable_from_iterable(
+ iterable: Iterable[Task],
+) -> AsyncIterable[Task]:
+ for item in iterable:
+ yield item
diff --git a/backend/beets_flask/importer/progress.py b/backend/beets_flask/importer/progress.py
index 7e848a3f..f625d95e 100644
--- a/backend/beets_flask/importer/progress.py
+++ b/backend/beets_flask/importer/progress.py
@@ -1,124 +1,124 @@
-from __future__ import annotations
-
-from dataclasses import dataclass
-from enum import Enum
-from functools import total_ordering
-from typing import TypedDict
-
-__all__ = [
- "FolderStatus",
- "Progress",
- "SerializedProgressState",
- "ProgressState",
-]
-
-
-@total_ordering
-class Progress(Enum):
- """The progress of tasks in chronological order.
-
- The order roughly matches the stages you might expect.
-
- Allows to resume a import at any time using our state dataclasses. We might
- also want to add the plugin stages or refine this.
- """
-
- NOT_STARTED = 0
-
- # PreviewSession
- READING_FILES = 10
- GROUPING_ALBUMS = 11
- LOOKING_UP_CANDIDATES = 12
- IDENTIFYING_DUPLICATES = 13
-
- PREVIEW_COMPLETED = 20 # dummy, only for comparison and report, has no actual stage
- DELETION_COMPLETED = 21 # dummy. after a successful deletion, we can restart import
-
- # ImportSession
- OFFERING_MATCHES = 30
- MATCH_THRESHOLD = 31
- WAITING_FOR_USER_SELECTION = 32
- EARLY_IMPORTING = 33
- IMPORTING = 34
- MANIPULATING_FILES = 35
-
- IMPORT_COMPLETED = 40 # also a dummy
-
- # UndoSession
- DELETING = 50
-
- def __lt__(self, other: Progress | ProgressState) -> bool:
- if isinstance(other, ProgressState):
- other = other.progress
- return self.value < other.value
-
- def __sub__(self, other: int) -> Progress:
- if not isinstance(other, int):
- raise TypeError("Unsupported type for addition")
-
- other = max(min(self.value - other, 50), 0)
- return Progress(other)
-
- def __add__(self, other: int) -> Progress:
- return self.__sub__(-1 * other)
-
-
-class SerializedProgressState(TypedDict):
- # ugly to repeat, but no way to read the type hint from enum.
- progress: Progress
- message: str | None
- plugin_name: str | None
-
-
-@total_ordering
-@dataclass(slots=True)
-class ProgressState:
- """Simple dataclass to hold a status message and a status code."""
-
- progress: Progress = Progress.NOT_STARTED
-
- # Optional message to display to the user
- message: str | None = None
-
- # Plugin specific
- plugin_name: str | None = None
-
- def serialize(self) -> SerializedProgressState:
- return SerializedProgressState(
- progress=self.progress,
- message=self.message,
- plugin_name=self.plugin_name,
- )
-
- def __lt__(self, other: ProgressState | Progress) -> bool:
- if isinstance(other, Progress):
- other = ProgressState(other)
- return self.progress < other.progress
-
- def __eq__(self, other: object) -> bool:
- if isinstance(other, Progress):
- return self.progress == other
- if not isinstance(other, ProgressState):
- return False
- return self.progress == other.progress
-
-
-class FolderStatus(int, Enum):
- """The status of a folder.
-
- Order does not matter, but we need to be able to check equality
- """
-
- UNKNOWN = -2
- FAILED = -1
- NOT_STARTED = 0
- PENDING = 1
- PREVIEWING = 2
- PREVIEWED = 3
- IMPORTING = 4
- IMPORTED = 5
- DELETING = 6
- DELETED = 7
-
- def __str__(self) -> str:
- return self.name.lower()
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import Enum
+from functools import total_ordering
+from typing import TypedDict
+
+__all__ = [
+ "FolderStatus",
+ "Progress",
+ "SerializedProgressState",
+ "ProgressState",
+]
+
+
+@total_ordering
+class Progress(Enum):
+ """The progress of tasks in chronological order.
+
+ The order roughly matches the stages you might expect.
+
+ Allows to resume a import at any time using our state dataclasses. We might
+ also want to add the plugin stages or refine this.
+ """
+
+ NOT_STARTED = 0
+
+ # PreviewSession
+ READING_FILES = 10
+ GROUPING_ALBUMS = 11
+ LOOKING_UP_CANDIDATES = 12
+ IDENTIFYING_DUPLICATES = 13
+
+ PREVIEW_COMPLETED = 20 # dummy, only for comparison and report, has no actual stage
+ DELETION_COMPLETED = 21 # dummy. after a successful deletion, we can restart import
+
+ # ImportSession
+ OFFERING_MATCHES = 30
+ MATCH_THRESHOLD = 31
+ WAITING_FOR_USER_SELECTION = 32
+ EARLY_IMPORTING = 33
+ IMPORTING = 34
+ MANIPULATING_FILES = 35
+
+ IMPORT_COMPLETED = 40 # also a dummy
+
+ # UndoSession
+ DELETING = 50
+
+ def __lt__(self, other: Progress | ProgressState) -> bool:
+ if isinstance(other, ProgressState):
+ other = other.progress
+ return self.value < other.value
+
+ def __sub__(self, other: int) -> Progress:
+ if not isinstance(other, int):
+ raise TypeError("Unsupported type for addition")
+
+ other = max(min(self.value - other, 50), 0)
+ return Progress(other)
+
+ def __add__(self, other: int) -> Progress:
+ return self.__sub__(-1 * other)
+
+
+class SerializedProgressState(TypedDict):
+ # ugly to repeat, but no way to read the type hint from enum.
+ progress: Progress
+ message: str | None
+ plugin_name: str | None
+
+
+@total_ordering
+@dataclass(slots=True)
+class ProgressState:
+ """Simple dataclass to hold a status message and a status code."""
+
+ progress: Progress = Progress.NOT_STARTED
+
+ # Optional message to display to the user
+ message: str | None = None
+
+ # Plugin specific
+ plugin_name: str | None = None
+
+ def serialize(self) -> SerializedProgressState:
+ return SerializedProgressState(
+ progress=self.progress,
+ message=self.message,
+ plugin_name=self.plugin_name,
+ )
+
+ def __lt__(self, other: ProgressState | Progress) -> bool:
+ if isinstance(other, Progress):
+ other = ProgressState(other)
+ return self.progress < other.progress
+
+ def __eq__(self, other: object) -> bool:
+ if isinstance(other, Progress):
+ return self.progress == other
+ if not isinstance(other, ProgressState):
+ return False
+ return self.progress == other.progress
+
+
+class FolderStatus(int, Enum):
+ """The status of a folder.
+
+ Order does not matter, but we need to be able to check equality
+ """
+
+ UNKNOWN = -2
+ FAILED = -1
+ NOT_STARTED = 0
+ PENDING = 1
+ PREVIEWING = 2
+ PREVIEWED = 3
+ IMPORTING = 4
+ IMPORTED = 5
+ DELETING = 6
+ DELETED = 7
+
+ def __str__(self) -> str:
+ return self.name.lower()
diff --git a/backend/beets_flask/importer/session.py b/backend/beets_flask/importer/session.py
index 8cad55ac..ec1edf2b 100644
--- a/backend/beets_flask/importer/session.py
+++ b/backend/beets_flask/importer/session.py
@@ -1,1142 +1,1142 @@
-"""
-Session classes for the import pipeline.
-
-Sessions often take particular arguments, such as a duplicate action. In the simplest and most common case,
-each session has one task (i.e. one album) to deal with. Sometimes, however, one session may have multiple tasks,
-such as when one folder contains files from two albums.
-
-To account for this, we use the TaskMapping type.
-They contain an action to take for each task (mapping a task_id as string to the action), and the default value
-must be None (which means that the session uses the default action for that task, loaded from user config).
-
-When no mapping is given, the default action is used for all tasks.
-
-```
-TaskIdMappingArg = defaultdict[str, T | None] | None
-```
-
-If you want to pass a value to all tasks, you can omit looking up the task ids and
-instead use "*" as the key, which will apply the action to all tasks of the session.
-
-```
-action_for_all : TaskIdMappingArg[DuplicateAction] = {"*": "remove"}
-```
-"""
-
-from __future__ import annotations
-
-import asyncio
-import logging
-from abc import ABC, abstractmethod
-from collections import defaultdict
-from collections.abc import Callable
-from copy import deepcopy
-from enum import Enum
-from pathlib import Path
-from typing import Any, Literal, TypedDict, TypeGuard, TypeVar
-
-import nest_asyncio
-from beets import autotag, importer, plugins
-from beets.ui import UserError, _open_library
-from beets.util import bytestring_path
-from deprecated import deprecated
-
-from beets_flask.config import get_config
-from beets_flask.disk import is_archive_file
-from beets_flask.importer.progress import Progress, ProgressState
-from beets_flask.importer.types import (
- BeetsAlbum,
- BeetsLibrary,
- DuplicateAction,
-)
-from beets_flask.logger import log
-from beets_flask.server.exceptions import (
- ApiException,
- DuplicateException,
- IntegrityException,
- NoCandidatesFoundException,
- NotImportedException,
- to_serialized_exception,
-)
-from beets_flask.utility import capture_stdout_stderr
-
-from .pipeline import AsyncPipeline
-from .stages import (
- StageOrder,
- finalize,
- group_albums,
- identify_duplicates,
- lookup_candidates,
- manipulate_files,
- match_threshold,
- plugin_stage,
- read_tasks,
- user_query,
-)
-from .states import ProgressState, SessionState
-
-nest_asyncio.apply()
-
-# ---------------------------------------------------------------------------- #
-# Types and helpers #
-# ---------------------------------------------------------------------------- #
-
-T = TypeVar("T")
-
-TaskIdMapping = defaultdict[str, T]
-TaskIdMappingArg = dict[str, T | None] | None
-
-
-def parse_task_id_mapping(mapping: TaskIdMappingArg[T], default: T) -> TaskIdMapping[T]:
- """
- Convert the flexible arguments to stricter TaskIdMapping that sessions use internally.
-
- Parameters
- ----------
- mapping : TaskIdMappingArg
- For each task_id (key) which action to take (value).
- If None, the default action is used for all tasks.
- If "*" is used as key, this action is used for all tasks, and only one key-value
- pair is allowed.
- default : T
- Default value to use for all tasks that are not in the mapping, or "*".
-
-
- Note
- ----
- TaskIdMappings are defaultdicts, which keeps the lower level logic simpler.
- TaskIdMappingsArgs are just dicts, which are serializable trhough api and redis
- thread bounds.
- """
-
- m: TaskIdMapping[T] = defaultdict(lambda: default)
- if mapping is not None:
- if "*" in mapping.keys():
- if len(mapping) > 1:
- raise ValueError(
- "If you use '*' as key, you cannot use any other keys in the mapping."
- )
- else:
- return defaultdict(lambda: mapping["*"] or default)
-
- for k, v in mapping.items():
- if v is None:
- continue
- m[k] = v
-
- return m
-
-
-class CandidateChoiceFallback(Enum):
- """
- Type for the candidate choice.
-
- Candidate Choices are either a string (the candidate id) or a special case
- (asis candidate, or the best one).
- """
-
- ASIS = 1
- BEST = 2
-
-
-CandidateChoice = str | CandidateChoiceFallback
-
-
-class Search(TypedDict):
- """Search for a candidate.
-
- This is used to search for a candidate in the preview session.
- """
-
- search_ids: list[str]
- search_artist: str | None
- search_album: str | None
-
-
-def _is_search(d: Any) -> TypeGuard[Search]:
- """Check if the given dict is a Search object."""
- return (
- d is not None
- and isinstance(d, dict)
- and "search_ids" in d
- and isinstance(d["search_ids"], list)
- )
-
-
-# ---------------------------------------------------------------------------- #
-# Sessions #
-# ---------------------------------------------------------------------------- #
-
-
-class BaseSession(importer.ImportSession, ABC):
- """Base class for our GUI-based ImportSessions.
-
- Operates on single Albums / files.
-
- Parameters
- ----------
- path : list[str]
- list of album folders to import
- config_overlay : str or dict
- path to a config file to overlay on top of the default config.
- Note that if `dict`, the lazyconfig notation e.g. `{import.default_action: skip}`
- wont work reliably. Better nest the dicts: `{import: {default_action: skip}}`
-
- Note: It's a design choice to require that you manually create and pass the
- `SessionState` object. Usually the states go into the database, which needs explizit
- handling beyond the session.
- """
-
- # attributes needed to create a beetsTag instance for our database
- # are contained in the associated SessionState -> TaskState -> CandidateStates
- state: SessionState
-
- pipeline: AsyncPipeline[importer.ImportTask, Any] | None = None
- config_overlay: dict
-
- # FIXME: only for typehint until we update beets
- lib: BeetsLibrary # type: ignore
-
- def __init__(
- self,
- state: SessionState,
- config_overlay: dict | None = None,
- ):
- if not state.path.exists():
- raise FileNotFoundError(f"Path {state.path} does not exist.")
- if state.path.is_file() and not is_archive_file(state.path):
- raise ValueError(
- f"Path {state.path} is not an archive file. "
- + "Importing singletons is not supported yet."
- )
-
- # FIXME: This is a super bad convention of the original beets.
- # We do not want to pollute a global config object every time a session runs.
- # This is fine for the cli tool, where each run creates only one session
- # but not for our long-running webserver.
- config = get_config()
- if isinstance(config_overlay, dict):
- config.set_args(config_overlay)
-
- self.config_overlay = config_overlay or {}
- self.state = state
-
- super().__init__(
- lib=_open_library(config),
- paths=[bytestring_path(state.path)],
- query=None,
- loghandler=None,
- )
- # Hacky workaround to use our logging, to allow plugins to communicate
- self.logger.handlers = log.handlers
- log.debug(f"Created new {self.__class__.__name__} for {state.path}")
-
- @property
- def path(self) -> Path:
- return self.state.path
-
- @deprecated
- def run_and_capture_output(self) -> tuple[str, str]:
- """Run the import session and capture the output.
-
- Uses the original beets import session run method,
- with lots of overhead.
- Sets self.preivew to output and error messages occuring during run.
-
- Returns
- -------
- tuple[str, str]: out, err
- """
- self.logger.debug(f"{self.paths}")
- out, err, _ = capture_stdout_stderr(self.run)
- self.preview = out + "\n\n" + err if err else out
- return out, err
-
- def get_config_value(self, key: str, type_func: Callable | None = None) -> Any:
- """Get a config value from the overlay or default.
-
- Use dots to separate levels.
- """
-
- path = key.split(".")
-
- overlay = self.config_overlay
- for p in path:
- overlay = overlay.get(p, {})
-
- # overlay takes priority
- if not isinstance(overlay, dict):
- return type_func(overlay) if type_func else overlay
-
- # get settings from user settings, this is not a dict, but confuse config
- # the confuse config views do not throw key errors, and their .get() is not
- # the same as dict.get(), but rather resolves the value.
- default = get_config()
- for p in path:
- default = default[p]
- default = default.get(type_func) if type_func else default.get()
- return default
-
- # -------------------------- State handling helpers -------------------------- #
-
- def set_task_progress(
- self, task: importer.ImportTask, progress: ProgressState | Progress | str
- ):
- """Set the progress for a task belonging to the session.
-
- If string is given it is set as the message of the current progress.
- Note: currently we only implement status on the level of the whole import session,
- but should eventually do this per selection (task).
- """
-
- task_state = self.state.get_task_state_for_task_raise(task)
-
- task_state.set_progress(progress)
-
- def get_task_progress(self, task: importer.ImportTask) -> ProgressState | None:
- """Get the progress of the task, via this sessions state."""
- task_state = self.state.get_task_state_for_task(task)
- return task_state.progress if task_state else None
-
- # -------------------------------- Stages -------------------------------- #
-
- @property
- @abstractmethod
- def stages(self) -> StageOrder:
- """Set the stages for the session.
-
- In Subclasses, define the order of stages here.
- """
- raise NotImplementedError("Implement in subclass")
-
- def resolve_duplicate(self, task: importer.ImportTask, found_duplicates):
- """Overload default resolve duplicate and skip it.
-
- This basically skips this stage.
- """
- self.logger.warning(
- "Skipping duplicate resolution. "
- + f"Your session should implement this! -> {self.__class__.__name__}"
- )
- task.set_choice(importer.Action.SKIP)
-
- def choose_item(self, task: importer.ImportTask):
- """Overload default choose item and skip it.
-
- This session should not reach this stage.
- """
- self.logger.debug(f"skipping choose_item {task}")
- return importer.Action.SKIP
-
- def should_resume(self, path):
- """Overload default should_resume and skip it.
-
- Should normally be no problem if the config is set correctly, but just
- in case.
- """
- self.logger.debug(f"skipping should_resume {path}")
- return False
-
- def identify_duplicates(self, task: importer.ImportTask):
- """For all candidates, check if they have duplicates in the library.
-
- This stage should only be run for preview sessions, but we still have
- some old code in stages.py/user_query().
- """
- raise NotImplementedError(
- f"This session should not reach this stage. {self.__class__.__name__}"
- )
-
- def lookup_candidates(self, task: importer.ImportTask):
- """Lookup candidates for the task.
-
- This stage should only be run for preview sessions, but we still have
- some old code in stages.py/user_query().
- """
- raise NotImplementedError(
- f"This session should not reach this stage. {self.__class__.__name__}"
- )
-
- def finalize(self, task: importer.ImportTask):
- """Last stage called and customizable any session."""
- self.logger.debug(f"Finalized {self} {task}")
-
- # ---------------------------------- Run --------------------------------- #
-
- def run_sync(self) -> SessionState:
- """Run the import session synchronously."""
- return asyncio.run(self.run_async())
-
- async def run_async(self) -> SessionState:
- """Run the import session asynchronously.
-
- Does not set tasks to completed at the end.
- Take care of this in subclasses.
- """
- # For now, until we improve the upstream beets config logic,
- # adhere to importer.ImportSession convention and create a local copy
- # of the config.
- config = get_config()
- self.set_config(config["import"])
-
- # TODO: check some config values. that are not compatible with our code.
- self.pipeline = AsyncPipeline(start_tasks=read_tasks(self))
-
- for s in self.stages.values():
- self.pipeline.add_stage(s)
-
- log.info(f"Running {self.__class__.__name__} on state<{self.state.id=}>.")
- log.debug(f"Running {len(self.pipeline.stages)} stages.")
-
- # reset exception state
- # TODO: To clear or not to clear,
- # exception hierarchy and own table for exceptions/warnings
- # self.state.exc = None
- plugins.send("import_begin", session=self)
- try:
- assert self.pipeline is not None
- await self.pipeline.run_async()
- except importer.ImportAbortError:
- log.debug(f"Interactive import session aborted by user")
- except ApiException as e:
- if e.persist_in_db:
- log.debug(f"Persisting exception {e} in session state")
- self.state.exc = to_serialized_exception(e)
- raise e
- except Exception as e:
- self.state.exc = to_serialized_exception(e)
- raise e
-
- log.info(f"Completed {self.__class__.__name__} on state<{self.state.id=}>.")
- return self.state
-
-
-class PreviewSession(BaseSession):
- """Preview what would be imported. Only fetches candidates."""
-
- group_albums: bool | None
- autotag: bool | None
-
- def __init__(
- self,
- state: SessionState,
- config_overlay: dict | None = None,
- group_albums: bool | None = None,
- autotag: bool | None = None,
- **kwargs,
- ):
- """
- Create new PreviewSession.
-
- Parameters
- ----------
- group_albums : bool | None
- Whether to create multple tasks, one for each album found in the metadata
- of the files. Set to true if you have multiple albums in a single folder.
- If None: get value from beets config.
- autotag : bool | None
- Whether to look up metadata online. If None: get value from beets config.
- """
-
- super().__init__(state, config_overlay, **kwargs)
- self.group_albums = group_albums
- self.autotag = autotag
-
- # -------------------------------- Stages -------------------------------- #
-
- @property
- def stages(self) -> StageOrder:
- stages = StageOrder()
-
- if self.get_config_value("import.singletons"):
- # beets tweaks the album grouping settings via overlay for singletons.
- raise NotImplementedError("Singletons not implemented yet.")
-
- if self.group_albums or (
- self.group_albums is None and self.get_config_value("import.group_albums")
- ):
- stages.append(group_albums(self))
-
- if self.autotag or (
- self.autotag is None and self.get_config_value("import.autotag")
- ):
- stages.append(lookup_candidates(self))
-
- stages.append(identify_duplicates(self))
- stages.append(finalize(self))
-
- return stages
-
- # --------------------------- Stage Definitions -------------------------- #
-
- def identify_duplicates(self, task: importer.ImportTask):
- """For all candidates, check if they have duplicates in the library."""
- task_state = self.state.get_task_state_for_task_raise(task)
-
- for idx, cs in enumerate(
- task_state.candidate_states + [task_state.asis_candidate]
- ):
- # This is a mutable operation i.e. candidate state is modfied here!
- duplicates = cs.identify_duplicates(self.lib)
-
- if len(duplicates) > 0:
- log.debug(f"Found duplicates for {cs.id=}: {duplicates}")
-
- def lookup_candidates(self, task: importer.ImportTask):
- """Lookup candidates for the task."""
-
- search_ids = self.config["search_ids"].as_str_seq() # might be an empty list
- log.debug(
- f"Looking up candidates for {task.paths}, using search ids {search_ids}"
- )
-
- task.lookup_candidates(search_ids)
-
- if len(task.candidates) == 0:
- raise NoCandidatesFoundException(persist_in_db=True)
-
- # Update our state
- task_state = self.state.get_task_state_for_task_raise(task)
- task_state.add_candidates(task.candidates)
-
-
-class AddCandidatesSession(PreviewSession):
- """
- Preview session that adds a candidate to the ones already fetched.
-
- Can only run on a session state of a preview session that already has
- candidates.
- """
-
- search: TaskIdMapping[Search | Literal["skip"]]
- initial_task_states: dict[str, ProgressState]
-
- def __init__(
- self,
- state: SessionState,
- config_overlay: dict | None = None,
- search: TaskIdMappingArg[Search | Literal["skip"]] = None,
- **kwargs,
- ):
- super().__init__(state, config_overlay, **kwargs)
-
- # None means skip search for this task
- self.search = parse_task_id_mapping(search, "skip")
- self.initial_task_states = {}
-
- for task in self.state.task_states:
- self.initial_task_states[task.id] = deepcopy(task.progress)
- if task.progress >= Progress.PREVIEW_COMPLETED:
- # Reset task progress only for tasks that have search values
- # other tasks are skipped
- # This should only be relevant for searches on multiple tasks
- # (i.e. folders)
- s = self.search[task.id]
- if s != "skip":
- task.set_progress(Progress.LOOKING_UP_CANDIDATES - 1)
-
- def lookup_candidates(self, task: importer.ImportTask):
- """Amend the found candidate to the already existing candidates (if any)."""
- # see ref in lookup_candidates in beets/importer.py
-
- task_state = self.state.get_task_state_for_task_raise(task)
- search = self.search[task_state.id]
-
- if search == "skip":
- log.debug(f"Skipping search for {task_state.id=}")
- return
-
- # Beets treats empty strings like real strings, which is not what we want here.
- # TODO: revisit once PR merged upstream: https://github.com/beetbox/beets/pull/6117
- if (
- search["search_artist"] is not None
- and search["search_artist"].strip() == ""
- ):
- search["search_artist"] = None
- if search["search_album"] is not None and search["search_album"].strip() == "":
- search["search_album"] = None
- search["search_ids"] = list(
- filter(lambda x: x.strip() != "", search["search_ids"])
- )
-
- log.debug(f"Using {search=} for {task_state.id=}, {task_state.paths=}")
-
- try:
- _, _, prop = autotag.tag_album(
- task.items,
- search_ids=search["search_ids"],
- search_album=search["search_album"],
- search_artist=search["search_artist"],
- )
- except Exception as e:
- # TODO: With beets 2.6.0 this should be revisited
- # since beets should than be able to handle these exceptions
- # gracefully upstream.
- # https://github.com/beetbox/beets/pull/5965
- from beetsplug.musicbrainz import MusicBrainzAPIError
- from beetsplug.spotify import APIError as SpotifyAPIError
-
- if isinstance(e, MusicBrainzAPIError):
- raise NoCandidatesFoundException(
- f"Failed to contact Musicbrainz API: {e.get_message()}",
- persist_in_db=False,
- )
- elif isinstance(e, SpotifyAPIError):
- raise NoCandidatesFoundException(
- f"Failed to contact Spotify API: {e}",
- persist_in_db=False,
- )
- else:
- raise NoCandidatesFoundException(
- f"Failed to contact online APIs.",
- persist_in_db=False,
- )
-
- task_state.add_candidates(prop.candidates)
-
- # Update quality of best candidate, likely not needed for us, only beets cli.
- task.rec = max(prop.recommendation, task.rec or autotag.Recommendation.none)
-
- if len(prop.candidates) == 0:
- error_text = "Search found no candidates via "
- if search["search_ids"]:
- error_text += f"ids: {', '.join(search['search_ids'])}; "
- if search["search_artist"]:
- error_text += f"artist: {search['search_artist']}; "
- if search["search_album"]:
- error_text += f"album: {search['search_album']}; "
- error_text += NoCandidatesFoundException.metadata_plugin_info()
- raise NoCandidatesFoundException(
- error_text,
- persist_in_db=False,
- )
- # Hack: Clear exception if we found new candidates
- elif (
- self.state.exc is not None
- and self.state.exc["type"] == "NoCandidatesFoundException"
- ):
- log.debug(
- "Clearing previous NoCandidatesFoundException after finding candidates via search."
- )
- self.state.exc = None
-
- def finalize(self, task: importer.ImportTask):
- """Restore initial taks and session states."""
-
- task_state = self.state.get_task_state_for_task_raise(task)
- if task_state.id in self.initial_task_states:
- task_state.set_progress(self.initial_task_states[task_state.id])
- else:
- log.warning(
- f"Task {task_state.id} not in initial task states. "
- + "Cannot restore previous progress."
- )
-
- self.logger.debug(f"Finalized {self} {task}")
-
-
-class ImportSession(BaseSession):
- """
- Import session that assumes we already have a match-id.
-
- Needs to run from an already finished Preview Session.
- """
-
- candidate_ids: TaskIdMapping[CandidateChoice]
- duplicate_actions: TaskIdMapping[DuplicateAction]
-
- def __init__(
- self,
- state: SessionState,
- config_overlay: dict | None = None,
- candidate_ids: TaskIdMappingArg[CandidateChoice] = None,
- duplicate_actions: TaskIdMappingArg[DuplicateAction] = None,
- ):
- """Create new ImportSession.
-
- Parameters
- ----------
- candidate_ids : optional
- Either id of candidate(s) or the import choice. This is used to determine which
- candidate to import. If a dict is given, the keys are the task ids and the
- values are the candidate ids. You can also use the import choice enum
- `ImportChoice.ASIS` or `ImportChoice.BEST` to indicate that you want to
- import the candidate as-is or the best candidate.
- FIXME: at the moment asis is broken
- duplicate_actions : str
- The action to take if duplicates are found. One of "skip", "keep",
- "remove", "merge", "ask". If None, the default is read from
- the user config and applied to all tasks.
- """
-
- config_overlay = {} if config_overlay is None else config_overlay
- if config_overlay.get("import", {}).get("search_ids") is not None:
- raise ValueError("search_ids set in config_overlay. This is not supported.")
-
- super().__init__(state, config_overlay)
-
- # Create a mapping for the duplicate action
- # each task might have a different action.
- # if none is given the default action is used from the config
- default_action: DuplicateAction = self.get_config_value(
- "import.duplicate_action", str
- )
- self.duplicate_actions = parse_task_id_mapping(
- duplicate_actions, default_action
- )
-
- # For candidates, None means to take best
- self.candidate_ids = parse_task_id_mapping(
- candidate_ids, CandidateChoiceFallback.BEST
- )
-
- async def run_async(self) -> SessionState:
- # only allow import sessions to run on preview states (not other import states)
- if self.state.progress == Progress.IMPORT_COMPLETED:
- log.error(
- f"Cannot run {self.__class__.__name__} from states that already "
- + f"completed an import. (i.e. other imports) [{self.state.progress}]"
- )
- e = UserError("Cannot redo imports. Try undo and/or retag!")
- self.state.exc = to_serialized_exception(e)
- raise e
- elif self.state.progress > Progress.PREVIEW_COMPLETED:
- log.warning(
- f"Resetting state from {self.state.progress} to PREVIEW_COMPLETED for "
- + f"import session {self.state.id}."
- )
- for task in self.state.task_states:
- task.set_progress(Progress.PREVIEW_COMPLETED)
-
- state = await super().run_async()
-
- # Trigger cli_exit event to have compatibility with beets plugin that
- # execute after the import is completed.
- plugins.send("cli_exit", lib=self.lib)
- return state
-
- # ------------------------------ Stages ------------------------------ #
-
- @property
- def stages(self):
- stages = StageOrder()
-
- # FIXME: user_query calls task.choose_match, which calls session.choose_match.
- # Better abstraction needed upstream.
- stages.append(user_query(self))
-
- # Early import stages
- plugs = plugins.find_plugins()
- for p in plugs:
- for stage in p.get_early_import_stages():
- stages.append(
- plugin_stage(
- self,
- stage,
- ProgressState(
- Progress.EARLY_IMPORTING,
- plugin_name=stage.__name__,
- ),
- ),
- name=f"early_plugin_stage_{p.__class__.__name__}_{stage.__name__}",
- )
-
- # Import stages
- for p in plugs:
- for stage in p.get_import_stages():
- stages.append(
- plugin_stage(
- self,
- stage,
- ProgressState(
- Progress.IMPORTING,
- plugin_name=stage.__name__,
- ),
- ),
- name=f"plugin_stage_{p.__class__.__name__}_{stage.__name__}",
- )
-
- # finally, move files
- stages.append(manipulate_files(self))
- stages.append(finalize(self))
-
- return stages
-
- def finalize(self, task: importer.ImportTask):
- """
- Reset previous match threshold exceptions.
-
- Needed because we might run a normal ImportSession manually after
- the AutoImportSession. The AutoImportSession needs to keep an Exception in its
- status to inform the frontend, which we need to clear here.
- (No need to override `finalize` in AutoImportSession, despite it inherriting
- from here, because it raises when below threshold).
- """
- if (
- self.state.exc is not None
- and self.state.exc["type"] == "NotImportedException"
- and self.state.exc["message"].startswith("Match below threshold")
- ):
- log.debug(
- "Clearing previous MatchThresholdException after successful import."
- )
- self.state.exc = None
- super().finalize(task)
-
- # --------------------------- Stage Definitions -------------------------- #
-
- def choose_match(self, task: importer.ImportTask):
- self.logger.setLevel(logging.DEBUG)
- self.logger.debug(f"choose_match {task}")
-
- task_state = self.state.get_task_state_for_task_raise(task)
-
- # Pick the candidate to import
- candidate_id = self.candidate_ids[task_state.id]
-
- if isinstance(candidate_id, str):
- candidate_state = task_state.get_candidate_state_by_id(candidate_id)
- if candidate_state is None:
- raise ValueError(f"Candidate with id {candidate_id} not found.")
- elif candidate_id == CandidateChoiceFallback.BEST:
- candidate_state = task_state.best_candidate_state
- if candidate_state is None:
- raise ValueError(f"No candidate found.")
- elif candidate_id == CandidateChoiceFallback.ASIS:
- candidate_state = task_state.asis_candidate
- else:
- raise NotImplementedError("ImportChoice.ASIS not implemented yet.")
-
- # update task_state to keep track of the choice in the database
- task_state.chosen_candidate_state_id = candidate_state.id
- log.debug(
- f"Setting chosen candidate for task {task_state.id} to {candidate_state.id}"
- )
-
- # Let plugins display info
- results = plugins.send("import_task_before_choice", session=self, task=task)
- actions = [action for action in results if action]
-
- if len(actions) > 0:
- # decide if we can just move past this and ignore the plugins
- raise UserError(
- f"Plugins returned actions, which is not supported for {self.__class__.__name__}"
- )
-
- # ASIS
- if candidate_state.id == task_state.asis_candidate.id:
- log.debug(f"Importing {task} as-is")
- return importer.Action.ASIS
-
- return candidate_state.match
-
- def resolve_duplicate(
- self, task: importer.ImportTask, found_duplicates: list[BeetsAlbum]
- ):
- log.debug(
- f"Resolving duplicates for {task} with action {self.duplicate_actions}"
- )
-
- if len(found_duplicates) == 0:
- log.debug(f"No duplicates found for")
- return
-
- task_state = self.state.get_task_state_for_task_raise(task)
- task_duplicate_action = self.duplicate_actions[task_state.id]
- task_state.duplicate_action = task_duplicate_action
- match task_duplicate_action:
- case "skip":
- task.set_choice(importer.Action.SKIP)
- case "keep":
- pass
- case "remove":
- task.should_remove_duplicates = True
- case "merge":
- task.should_merge_duplicates = True
- case "ask":
- # task.set_choice(importer.action.SKIP)
- raise DuplicateException(
- "You have set the duplicate action to 'ask' in your beets config."
- )
- case _:
- raise DuplicateException(
- f"Unknown duplicate action: {self.duplicate_actions}"
- )
-
-
-class BootlegImportSession(ImportSession):
- """
- Import session to import without modifying metadata.
-
- No preview session required.
-
- Essentially `beet import --group-albums -A`, ideal for bootlegs and
- just getting a folder into your library where you are sure the metadata is correct.
- """
-
- def __init__(
- self,
- state,
- config_overlay: dict | None = None,
- **kwargs,
- ):
- """Create new ImportAsIsSession.
-
- Parameters
- ----------
- state: SessionState
- Preconfigured state of the import session.
- config_overlay : dict
- Configuration to overlay on top of the default config.
- "import.group_albums", "import.autotag" and "import.search_ids" are ignored.
- **kwargs
- See `ImportSession`.
- """
-
- config_overlay = {} if config_overlay is None else deepcopy(config_overlay)
- import_overlay = config_overlay.get("import", {})
-
- if "group_albums" in import_overlay and import_overlay["group_albums"] is False:
- log.warning("Overwriting 'group_albums' in config_overlay.")
- if "autotag" in import_overlay and import_overlay["autotag"] is True:
- log.warning("Overwriting 'autotag' in config_overlay.")
- if "search_ids" in import_overlay:
- log.warning("Overwriting 'search_ids' in config_overlay.")
-
- import_overlay["group_albums"] = True
- import_overlay["autotag"] = False
- import_overlay["search_ids"] = None
-
- config_overlay["import"] = import_overlay
-
- super().__init__(state, config_overlay, **kwargs)
-
- # overwrite the default action for all tasks
- self.candidate_ids = parse_task_id_mapping(
- {"*": CandidateChoiceFallback.ASIS},
- CandidateChoiceFallback.ASIS,
- )
-
- @property
- def stages(self):
- stages = super().stages
- stages.insert(before="user_query", stage=group_albums(self))
- return stages
-
-
-class AutoImportSession(ImportSession):
- """Generate a preview and import if the best match is good enough.
-
- Preview generation is skipped if the provided session state already has a preview.
-
- Wether the import is triggered depends on the specified `import_threshold`, or
- the beets-config setting `match.strong_rec_thresh`.
- The match quality is calculated via penalties, thus it ranges from 0 to 1, but a
- perfect match is at 0. The same convention is used for thresholds.
-
- The default threshold is 0.04, so that a "96% match or better" will be imported.
-
- Raises a `NotImportedException` if the match quality is worse than the threshold,
- stopping the pipeline.
- """
-
- import_threshold: float
-
- def __init__(
- self,
- state,
- config_overlay: dict | None = None,
- import_threshold: float | None = None,
- **kwargs,
- ):
- """Create new AutoImportSession.
-
- Parameters
- ----------
- state: SessionState
- Preconfigured state of the import session.
- config_overlay : optional, dict
- Configuration to overlay on top of the default config.
- import_threshold: optional, float
- 0 to import only perfect matches, 1 to import everything. Default is 0.04.
- **kwargs
- See `ImportSession`.
- """
-
- super().__init__(state, config_overlay, **kwargs)
-
- if import_threshold is None:
- self.import_threshold = self.get_config_value(
- "match.strong_rec_thresh", float
- )
- else:
- self.import_threshold = import_threshold
-
- @property
- def stages(self):
- stages = super().stages
- stages.insert(before="user_query", stage=match_threshold(self))
- return stages
-
- def match_threshold(self, task: importer.ImportTask):
- """Check if the match quality is good enough to import.
-
- Returns true if candidates were found, and the match quality is better than
- threshlold.
-
- Note: What stops the pipeline is that we set task.choice to importer.action.SKIP,
- or raise an exception.
-
- Currently raising, as we do not have a dedicated progress for "not imported".
-
- FIXME: Instead of adding a whole new stage, with progress and a session function,
- we could simply extend the choose_match function. It's defined for the normal
- import session, and gets called early in user_query.
- (see #78)
- """
- try:
- task_state = self.state.get_task_state_for_task(task)
- distance = float(task_state.best_candidate_state.distance) # type: ignore
- except (AttributeError, TypeError):
- distance = 2.0
-
- if len(task.candidates) == 0:
- raise NoCandidatesFoundException()
-
- if distance > self.import_threshold:
- log.debug(
- f"Best candidate was worse than threshold {distance=} {self.import_threshold=}"
- )
- d = (1 - distance) * 100
- t = (1 - self.import_threshold) * 100
- raise NotImportedException(f"Match below threshold ({d:.0f}% < {t:.0f}%)")
- # beets would handle this via the task action:
- task.set_choice(importer.action.SKIP)
- else:
- log.info(
- f"Best candidate was better than threshold, importing to library. {distance=} {self.import_threshold=}"
- )
-
-
-class UndoSession(BaseSession):
- delete_files: bool
-
- def __init__(
- self,
- state: SessionState,
- config_overlay: dict | None = None,
- delete_files: bool = False,
- **kwargs,
- ):
- super().__init__(state, config_overlay, **kwargs)
- self.delete_files = delete_files
-
- async def run_async(self) -> SessionState:
- """Undo an import.
-
- A bit of a hack to reuse the BaseSession as we do
- not operate on tasks here but makes things easier in
- calling this in the invoker.
-
- Note: this Session should only be run in the (single-thread) import queue,
- because we want to limit beets-library and file interactions to be serial
- to avoid conflicts.
- """
-
- if self.state.progress != Progress.IMPORT_COMPLETED:
- log.error(
- f"Cannot undo import from state {self.state.progress}. "
- + "Only imports can be undone."
- )
- e = UserError(
- "Cannot undo if never imported! You need to import to undo first."
- )
- self.state.exc = to_serialized_exception(e)
- raise e
-
- for t_state in self.state.task_states:
- t_state.set_progress(Progress.DELETING)
-
- # Revert the movement of items that beets does during manipulate_files()
- # TODO: support Move operations (currently we only allow copy)
-
- # HACK: To allow an reimport after an undo, we need to use the old
- # paths of the items. This is a bit hacky, but We did not find
- # a better way to do this while maintaining the original beets logic.
- # -> the old_paths attribute of a beets_task is set during task.manipulate_files()
- # Unfortunately, here task.imported_items() does not work yet (.match not set)
-
- for t_state in self.state.task_states:
- chosen_candidate = t_state.chosen_candidate_state
- if chosen_candidate is None:
- raise ValueError("No chosen candidate found for task.")
- if t_state.task.old_paths is None:
- raise ValueError("No old paths found for task.")
- for idx, item in enumerate(chosen_candidate.match.mapping.keys()): # type: ignore
- # yes, keys are the items and need to be updated!
- # mapping maps objects to objects.
- item.path = t_state.task.old_paths[idx]
-
- # Delete files
- excs: list[Exception] = []
- pths: list[Path] = []
- for t_state in self.state.task_states:
- try:
- delete_from_beets(t_state.id, self.delete_files, self.lib)
- except Exception as e:
- items = self.lib.items(f"gui_import_id:{t_state.id}")
-
- excs.append(e)
- pths.extend([Path(item.path.decode("utf-8")) for item in items])
-
- if len(excs) > 0:
- for exc in excs:
- log.exception(exc)
-
- # FIXME: Multi/array exceptions need handling
- self.state.exc = to_serialized_exception(excs[0])
- raise IntegrityException(
- "Could not delete all items. Some items might be left in the library. "
- + f"Problematic files were: {pths}"
- )
-
- # Update our state and progress
- for t_state in self.state.task_states:
- t_state.set_progress(Progress.DELETION_COMPLETED)
-
- # Send cli_exit event to have compatibility with beets plugin that
- # execute after the import is completed.
- plugins.send("cli_exit", lib=self.lib)
-
- return self.state
-
- @property
- def stages(self):
- # This tweaked session skips the pipeline
- return StageOrder()
-
-
-## Edge cases
-# 1. Session doesn't exist anymore -> delete
-# 2. Session exists but multiple tasks -> undo (session)
-# 3. Session exists but file do not anymore in import folder -> undo, warnings
-
-# Flow:
-# - remove from beets db
-# - revert our session (if possible)
-
-
-def delete_from_beets(task_id: str, delete_files: bool, lib: BeetsLibrary):
- """Low-level, delete the items from the beets library."""
-
- # We set a gui_import_id in the beets database this is equal to the session id
- # see _apply_choice in stages.py
-
- items = lib.items(f"gui_import_id:{task_id}")
-
- if len(items) == 0:
- raise ValueError("No items found that match this import session id.")
-
- with lib.transaction():
- for item in items:
- item.remove(delete_files)
+"""
+Session classes for the import pipeline.
+
+Sessions often take particular arguments, such as a duplicate action. In the simplest and most common case,
+each session has one task (i.e. one album) to deal with. Sometimes, however, one session may have multiple tasks,
+such as when one folder contains files from two albums.
+
+To account for this, we use the TaskMapping type.
+They contain an action to take for each task (mapping a task_id as string to the action), and the default value
+must be None (which means that the session uses the default action for that task, loaded from user config).
+
+When no mapping is given, the default action is used for all tasks.
+
+```
+TaskIdMappingArg = defaultdict[str, T | None] | None
+```
+
+If you want to pass a value to all tasks, you can omit looking up the task ids and
+instead use "*" as the key, which will apply the action to all tasks of the session.
+
+```
+action_for_all : TaskIdMappingArg[DuplicateAction] = {"*": "remove"}
+```
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from abc import ABC, abstractmethod
+from collections import defaultdict
+from collections.abc import Callable
+from copy import deepcopy
+from enum import Enum
+from pathlib import Path
+from typing import Any, Literal, TypedDict, TypeGuard, TypeVar
+
+import nest_asyncio
+from beets import autotag, importer, plugins
+from beets.ui import UserError, _open_library
+from beets.util import bytestring_path
+from deprecated import deprecated
+
+from beets_flask.config import get_config
+from beets_flask.disk import is_archive_file
+from beets_flask.importer.progress import Progress, ProgressState
+from beets_flask.importer.types import (
+ BeetsAlbum,
+ BeetsLibrary,
+ DuplicateAction,
+)
+from beets_flask.logger import log
+from beets_flask.server.exceptions import (
+ ApiException,
+ DuplicateException,
+ IntegrityException,
+ NoCandidatesFoundException,
+ NotImportedException,
+ to_serialized_exception,
+)
+from beets_flask.utility import capture_stdout_stderr
+
+from .pipeline import AsyncPipeline
+from .stages import (
+ StageOrder,
+ finalize,
+ group_albums,
+ identify_duplicates,
+ lookup_candidates,
+ manipulate_files,
+ match_threshold,
+ plugin_stage,
+ read_tasks,
+ user_query,
+)
+from .states import ProgressState, SessionState
+
+nest_asyncio.apply()
+
+# ---------------------------------------------------------------------------- #
+# Types and helpers #
+# ---------------------------------------------------------------------------- #
+
+T = TypeVar("T")
+
+TaskIdMapping = defaultdict[str, T]
+TaskIdMappingArg = dict[str, T | None] | None
+
+
+def parse_task_id_mapping(mapping: TaskIdMappingArg[T], default: T) -> TaskIdMapping[T]:
+ """
+ Convert the flexible arguments to stricter TaskIdMapping that sessions use internally.
+
+ Parameters
+ ----------
+ mapping : TaskIdMappingArg
+ For each task_id (key) which action to take (value).
+ If None, the default action is used for all tasks.
+ If "*" is used as key, this action is used for all tasks, and only one key-value
+ pair is allowed.
+ default : T
+ Default value to use for all tasks that are not in the mapping, or "*".
+
+
+ Note
+ ----
+ TaskIdMappings are defaultdicts, which keeps the lower level logic simpler.
+ TaskIdMappingsArgs are just dicts, which are serializable trhough api and redis
+ thread bounds.
+ """
+
+ m: TaskIdMapping[T] = defaultdict(lambda: default)
+ if mapping is not None:
+ if "*" in mapping.keys():
+ if len(mapping) > 1:
+ raise ValueError(
+ "If you use '*' as key, you cannot use any other keys in the mapping."
+ )
+ else:
+ return defaultdict(lambda: mapping["*"] or default)
+
+ for k, v in mapping.items():
+ if v is None:
+ continue
+ m[k] = v
+
+ return m
+
+
+class CandidateChoiceFallback(Enum):
+ """
+ Type for the candidate choice.
+
+ Candidate Choices are either a string (the candidate id) or a special case
+ (asis candidate, or the best one).
+ """
+
+ ASIS = 1
+ BEST = 2
+
+
+CandidateChoice = str | CandidateChoiceFallback
+
+
+class Search(TypedDict):
+ """Search for a candidate.
+
+ This is used to search for a candidate in the preview session.
+ """
+
+ search_ids: list[str]
+ search_artist: str | None
+ search_album: str | None
+
+
+def _is_search(d: Any) -> TypeGuard[Search]:
+ """Check if the given dict is a Search object."""
+ return (
+ d is not None
+ and isinstance(d, dict)
+ and "search_ids" in d
+ and isinstance(d["search_ids"], list)
+ )
+
+
+# ---------------------------------------------------------------------------- #
+# Sessions #
+# ---------------------------------------------------------------------------- #
+
+
+class BaseSession(importer.ImportSession, ABC):
+ """Base class for our GUI-based ImportSessions.
+
+ Operates on single Albums / files.
+
+ Parameters
+ ----------
+ path : list[str]
+ list of album folders to import
+ config_overlay : str or dict
+ path to a config file to overlay on top of the default config.
+ Note that if `dict`, the lazyconfig notation e.g. `{import.default_action: skip}`
+ wont work reliably. Better nest the dicts: `{import: {default_action: skip}}`
+
+ Note: It's a design choice to require that you manually create and pass the
+ `SessionState` object. Usually the states go into the database, which needs explizit
+ handling beyond the session.
+ """
+
+ # attributes needed to create a beetsTag instance for our database
+ # are contained in the associated SessionState -> TaskState -> CandidateStates
+ state: SessionState
+
+ pipeline: AsyncPipeline[importer.ImportTask, Any] | None = None
+ config_overlay: dict
+
+ # FIXME: only for typehint until we update beets
+ lib: BeetsLibrary # type: ignore
+
+ def __init__(
+ self,
+ state: SessionState,
+ config_overlay: dict | None = None,
+ ):
+ if not state.path.exists():
+ raise FileNotFoundError(f"Path {state.path} does not exist.")
+ if state.path.is_file() and not is_archive_file(state.path):
+ raise ValueError(
+ f"Path {state.path} is not an archive file. "
+ + "Importing singletons is not supported yet."
+ )
+
+ # FIXME: This is a super bad convention of the original beets.
+ # We do not want to pollute a global config object every time a session runs.
+ # This is fine for the cli tool, where each run creates only one session
+ # but not for our long-running webserver.
+ config = get_config()
+ if isinstance(config_overlay, dict):
+ config.set_args(config_overlay)
+
+ self.config_overlay = config_overlay or {}
+ self.state = state
+
+ super().__init__(
+ lib=_open_library(config),
+ paths=[bytestring_path(state.path)],
+ query=None,
+ loghandler=None,
+ )
+ # Hacky workaround to use our logging, to allow plugins to communicate
+ self.logger.handlers = log.handlers
+ log.debug(f"Created new {self.__class__.__name__} for {state.path}")
+
+ @property
+ def path(self) -> Path:
+ return self.state.path
+
+ @deprecated
+ def run_and_capture_output(self) -> tuple[str, str]:
+ """Run the import session and capture the output.
+
+ Uses the original beets import session run method,
+ with lots of overhead.
+ Sets self.preivew to output and error messages occuring during run.
+
+ Returns
+ -------
+ tuple[str, str]: out, err
+ """
+ self.logger.debug(f"{self.paths}")
+ out, err, _ = capture_stdout_stderr(self.run)
+ self.preview = out + "\n\n" + err if err else out
+ return out, err
+
+ def get_config_value(self, key: str, type_func: Callable | None = None) -> Any:
+ """Get a config value from the overlay or default.
+
+ Use dots to separate levels.
+ """
+
+ path = key.split(".")
+
+ overlay = self.config_overlay
+ for p in path:
+ overlay = overlay.get(p, {})
+
+ # overlay takes priority
+ if not isinstance(overlay, dict):
+ return type_func(overlay) if type_func else overlay
+
+ # get settings from user settings, this is not a dict, but confuse config
+ # the confuse config views do not throw key errors, and their .get() is not
+ # the same as dict.get(), but rather resolves the value.
+ default = get_config()
+ for p in path:
+ default = default[p]
+ default = default.get(type_func) if type_func else default.get() # type: ignore[call-overload]
+ return default
+
+ # -------------------------- State handling helpers -------------------------- #
+
+ def set_task_progress(
+ self, task: importer.ImportTask, progress: ProgressState | Progress | str
+ ):
+ """Set the progress for a task belonging to the session.
+
+ If string is given it is set as the message of the current progress.
+ Note: currently we only implement status on the level of the whole import session,
+ but should eventually do this per selection (task).
+ """
+
+ task_state = self.state.get_task_state_for_task_raise(task)
+
+ task_state.set_progress(progress)
+
+ def get_task_progress(self, task: importer.ImportTask) -> ProgressState | None:
+ """Get the progress of the task, via this sessions state."""
+ task_state = self.state.get_task_state_for_task(task)
+ return task_state.progress if task_state else None
+
+ # -------------------------------- Stages -------------------------------- #
+
+ @property
+ @abstractmethod
+ def stages(self) -> StageOrder:
+ """Set the stages for the session.
+
+ In Subclasses, define the order of stages here.
+ """
+ raise NotImplementedError("Implement in subclass")
+
+ def resolve_duplicate(self, task: importer.ImportTask, found_duplicates):
+ """Overload default resolve duplicate and skip it.
+
+ This basically skips this stage.
+ """
+ self.logger.warning(
+ "Skipping duplicate resolution. "
+ + f"Your session should implement this! -> {self.__class__.__name__}"
+ )
+ task.set_choice(importer.Action.SKIP)
+
+ def choose_item(self, task: importer.ImportTask):
+ """Overload default choose item and skip it.
+
+ This session should not reach this stage.
+ """
+ self.logger.debug(f"skipping choose_item {task}")
+ return importer.Action.SKIP
+
+ def should_resume(self, path):
+ """Overload default should_resume and skip it.
+
+ Should normally be no problem if the config is set correctly, but just
+ in case.
+ """
+ self.logger.debug(f"skipping should_resume {path}")
+ return False
+
+ def identify_duplicates(self, task: importer.ImportTask):
+ """For all candidates, check if they have duplicates in the library.
+
+ This stage should only be run for preview sessions, but we still have
+ some old code in stages.py/user_query().
+ """
+ raise NotImplementedError(
+ f"This session should not reach this stage. {self.__class__.__name__}"
+ )
+
+ def lookup_candidates(self, task: importer.ImportTask):
+ """Lookup candidates for the task.
+
+ This stage should only be run for preview sessions, but we still have
+ some old code in stages.py/user_query().
+ """
+ raise NotImplementedError(
+ f"This session should not reach this stage. {self.__class__.__name__}"
+ )
+
+ def finalize(self, task: importer.ImportTask):
+ """Last stage called and customizable any session."""
+ self.logger.debug(f"Finalized {self} {task}")
+
+ # ---------------------------------- Run --------------------------------- #
+
+ def run_sync(self) -> SessionState:
+ """Run the import session synchronously."""
+ return asyncio.run(self.run_async())
+
+ async def run_async(self) -> SessionState:
+ """Run the import session asynchronously.
+
+ Does not set tasks to completed at the end.
+ Take care of this in subclasses.
+ """
+ # For now, until we improve the upstream beets config logic,
+ # adhere to importer.ImportSession convention and create a local copy
+ # of the config.
+ config = get_config()
+ self.set_config(config["import"])
+
+ # TODO: check some config values. that are not compatible with our code.
+ self.pipeline = AsyncPipeline(start_tasks=read_tasks(self))
+
+ for s in self.stages.values():
+ self.pipeline.add_stage(s)
+
+ log.info(f"Running {self.__class__.__name__} on state<{self.state.id=}>.")
+ log.debug(f"Running {len(self.pipeline.stages)} stages.")
+
+ # reset exception state
+ # TODO: To clear or not to clear,
+ # exception hierarchy and own table for exceptions/warnings
+ # self.state.exc = None
+ plugins.send("import_begin", session=self)
+ try:
+ assert self.pipeline is not None
+ await self.pipeline.run_async()
+ except importer.ImportAbortError:
+ log.debug(f"Interactive import session aborted by user")
+ except ApiException as e:
+ if e.persist_in_db:
+ log.debug(f"Persisting exception {e} in session state")
+ self.state.exc = to_serialized_exception(e)
+ raise e
+ except Exception as e:
+ self.state.exc = to_serialized_exception(e)
+ raise e
+
+ log.info(f"Completed {self.__class__.__name__} on state<{self.state.id=}>.")
+ return self.state
+
+
+class PreviewSession(BaseSession):
+ """Preview what would be imported. Only fetches candidates."""
+
+ group_albums: bool | None
+ autotag: bool | None
+
+ def __init__(
+ self,
+ state: SessionState,
+ config_overlay: dict | None = None,
+ group_albums: bool | None = None,
+ autotag: bool | None = None,
+ **kwargs,
+ ):
+ """
+ Create new PreviewSession.
+
+ Parameters
+ ----------
+ group_albums : bool | None
+ Whether to create multple tasks, one for each album found in the metadata
+ of the files. Set to true if you have multiple albums in a single folder.
+ If None: get value from beets config.
+ autotag : bool | None
+ Whether to look up metadata online. If None: get value from beets config.
+ """
+
+ super().__init__(state, config_overlay, **kwargs)
+ self.group_albums = group_albums
+ self.autotag = autotag
+
+ # -------------------------------- Stages -------------------------------- #
+
+ @property
+ def stages(self) -> StageOrder:
+ stages = StageOrder()
+
+ if self.get_config_value("import.singletons"):
+ # beets tweaks the album grouping settings via overlay for singletons.
+ raise NotImplementedError("Singletons not implemented yet.")
+
+ if self.group_albums or (
+ self.group_albums is None and self.get_config_value("import.group_albums")
+ ):
+ stages.append(group_albums(self))
+
+ if self.autotag or (
+ self.autotag is None and self.get_config_value("import.autotag")
+ ):
+ stages.append(lookup_candidates(self))
+
+ stages.append(identify_duplicates(self))
+ stages.append(finalize(self))
+
+ return stages
+
+ # --------------------------- Stage Definitions -------------------------- #
+
+ def identify_duplicates(self, task: importer.ImportTask):
+ """For all candidates, check if they have duplicates in the library."""
+ task_state = self.state.get_task_state_for_task_raise(task)
+
+ for idx, cs in enumerate(
+ task_state.candidate_states + [task_state.asis_candidate]
+ ):
+ # This is a mutable operation i.e. candidate state is modfied here!
+ duplicates = cs.identify_duplicates(self.lib)
+
+ if len(duplicates) > 0:
+ log.debug(f"Found duplicates for {cs.id=}: {duplicates}")
+
+ def lookup_candidates(self, task: importer.ImportTask):
+ """Lookup candidates for the task."""
+
+ search_ids = self.config["search_ids"].as_str_seq() # might be an empty list
+ log.debug(
+ f"Looking up candidates for {task.paths}, using search ids {search_ids}"
+ )
+
+ task.lookup_candidates(search_ids)
+
+ if len(task.candidates) == 0:
+ raise NoCandidatesFoundException(persist_in_db=True)
+
+ # Update our state
+ task_state = self.state.get_task_state_for_task_raise(task)
+ task_state.add_candidates(task.candidates)
+
+
+class AddCandidatesSession(PreviewSession):
+ """
+ Preview session that adds a candidate to the ones already fetched.
+
+ Can only run on a session state of a preview session that already has
+ candidates.
+ """
+
+ search: TaskIdMapping[Search | Literal["skip"]]
+ initial_task_states: dict[str, ProgressState]
+
+ def __init__(
+ self,
+ state: SessionState,
+ config_overlay: dict | None = None,
+ search: TaskIdMappingArg[Search | Literal["skip"]] = None,
+ **kwargs,
+ ):
+ super().__init__(state, config_overlay, **kwargs)
+
+ # None means skip search for this task
+ self.search = parse_task_id_mapping(search, "skip")
+ self.initial_task_states = {}
+
+ for task in self.state.task_states:
+ self.initial_task_states[task.id] = deepcopy(task.progress)
+ if task.progress >= Progress.PREVIEW_COMPLETED:
+ # Reset task progress only for tasks that have search values
+ # other tasks are skipped
+ # This should only be relevant for searches on multiple tasks
+ # (i.e. folders)
+ s = self.search[task.id]
+ if s != "skip":
+ task.set_progress(Progress.LOOKING_UP_CANDIDATES - 1)
+
+ def lookup_candidates(self, task: importer.ImportTask):
+ """Amend the found candidate to the already existing candidates (if any)."""
+ # see ref in lookup_candidates in beets/importer.py
+
+ task_state = self.state.get_task_state_for_task_raise(task)
+ search = self.search[task_state.id]
+
+ if search == "skip":
+ log.debug(f"Skipping search for {task_state.id=}")
+ return
+
+ # Beets treats empty strings like real strings, which is not what we want here.
+ # TODO: revisit once PR merged upstream: https://github.com/beetbox/beets/pull/6117
+ if (
+ search["search_artist"] is not None
+ and search["search_artist"].strip() == ""
+ ):
+ search["search_artist"] = None
+ if search["search_album"] is not None and search["search_album"].strip() == "":
+ search["search_album"] = None
+ search["search_ids"] = list(
+ filter(lambda x: x.strip() != "", search["search_ids"])
+ )
+
+ log.debug(f"Using {search=} for {task_state.id=}, {task_state.paths=}")
+
+ try:
+ _, _, prop = autotag.tag_album(
+ task.items,
+ search_ids=search["search_ids"],
+ search_album=search["search_album"],
+ search_artist=search["search_artist"],
+ )
+ except Exception as e:
+ # TODO: With beets 2.6.0 this should be revisited
+ # since beets should than be able to handle these exceptions
+ # gracefully upstream.
+ # https://github.com/beetbox/beets/pull/5965
+ from beetsplug.musicbrainz import MusicBrainzAPIError
+ from beetsplug.spotify import APIError as SpotifyAPIError
+
+ if isinstance(e, MusicBrainzAPIError):
+ raise NoCandidatesFoundException(
+ f"Failed to contact Musicbrainz API: {e.get_message()}",
+ persist_in_db=False,
+ )
+ elif isinstance(e, SpotifyAPIError):
+ raise NoCandidatesFoundException(
+ f"Failed to contact Spotify API: {e}",
+ persist_in_db=False,
+ )
+ else:
+ raise NoCandidatesFoundException(
+ f"Failed to contact online APIs.",
+ persist_in_db=False,
+ )
+
+ task_state.add_candidates(prop.candidates)
+
+ # Update quality of best candidate, likely not needed for us, only beets cli.
+ task.rec = max(prop.recommendation, task.rec or autotag.Recommendation.none)
+
+ if len(prop.candidates) == 0:
+ error_text = "Search found no candidates via "
+ if search["search_ids"]:
+ error_text += f"ids: {', '.join(search['search_ids'])}; "
+ if search["search_artist"]:
+ error_text += f"artist: {search['search_artist']}; "
+ if search["search_album"]:
+ error_text += f"album: {search['search_album']}; "
+ error_text += NoCandidatesFoundException.metadata_plugin_info()
+ raise NoCandidatesFoundException(
+ error_text,
+ persist_in_db=False,
+ )
+ # Hack: Clear exception if we found new candidates
+ elif (
+ self.state.exc is not None
+ and self.state.exc["type"] == "NoCandidatesFoundException"
+ ):
+ log.debug(
+ "Clearing previous NoCandidatesFoundException after finding candidates via search."
+ )
+ self.state.exc = None
+
+ def finalize(self, task: importer.ImportTask):
+ """Restore initial taks and session states."""
+
+ task_state = self.state.get_task_state_for_task_raise(task)
+ if task_state.id in self.initial_task_states:
+ task_state.set_progress(self.initial_task_states[task_state.id])
+ else:
+ log.warning(
+ f"Task {task_state.id} not in initial task states. "
+ + "Cannot restore previous progress."
+ )
+
+ self.logger.debug(f"Finalized {self} {task}")
+
+
+class ImportSession(BaseSession):
+ """
+ Import session that assumes we already have a match-id.
+
+ Needs to run from an already finished Preview Session.
+ """
+
+ candidate_ids: TaskIdMapping[CandidateChoice]
+ duplicate_actions: TaskIdMapping[DuplicateAction]
+
+ def __init__(
+ self,
+ state: SessionState,
+ config_overlay: dict | None = None,
+ candidate_ids: TaskIdMappingArg[CandidateChoice] = None,
+ duplicate_actions: TaskIdMappingArg[DuplicateAction] = None,
+ ):
+ """Create new ImportSession.
+
+ Parameters
+ ----------
+ candidate_ids : optional
+ Either id of candidate(s) or the import choice. This is used to determine which
+ candidate to import. If a dict is given, the keys are the task ids and the
+ values are the candidate ids. You can also use the import choice enum
+ `ImportChoice.ASIS` or `ImportChoice.BEST` to indicate that you want to
+ import the candidate as-is or the best candidate.
+ FIXME: at the moment asis is broken
+ duplicate_actions : str
+ The action to take if duplicates are found. One of "skip", "keep",
+ "remove", "merge", "ask". If None, the default is read from
+ the user config and applied to all tasks.
+ """
+
+ config_overlay = {} if config_overlay is None else config_overlay
+ if config_overlay.get("import", {}).get("search_ids") is not None:
+ raise ValueError("search_ids set in config_overlay. This is not supported.")
+
+ super().__init__(state, config_overlay)
+
+ # Create a mapping for the duplicate action
+ # each task might have a different action.
+ # if none is given the default action is used from the config
+ default_action: DuplicateAction = self.get_config_value(
+ "import.duplicate_action", str
+ )
+ self.duplicate_actions = parse_task_id_mapping(
+ duplicate_actions, default_action
+ )
+
+ # For candidates, None means to take best
+ self.candidate_ids = parse_task_id_mapping(
+ candidate_ids, CandidateChoiceFallback.BEST
+ )
+
+ async def run_async(self) -> SessionState:
+ # only allow import sessions to run on preview states (not other import states)
+ if self.state.progress == Progress.IMPORT_COMPLETED:
+ log.error(
+ f"Cannot run {self.__class__.__name__} from states that already "
+ + f"completed an import. (i.e. other imports) [{self.state.progress}]"
+ )
+ e = UserError("Cannot redo imports. Try undo and/or retag!")
+ self.state.exc = to_serialized_exception(e)
+ raise e
+ elif self.state.progress > Progress.PREVIEW_COMPLETED:
+ log.warning(
+ f"Resetting state from {self.state.progress} to PREVIEW_COMPLETED for "
+ + f"import session {self.state.id}."
+ )
+ for task in self.state.task_states:
+ task.set_progress(Progress.PREVIEW_COMPLETED)
+
+ state = await super().run_async()
+
+ # Trigger cli_exit event to have compatibility with beets plugin that
+ # execute after the import is completed.
+ plugins.send("cli_exit", lib=self.lib)
+ return state
+
+ # ------------------------------ Stages ------------------------------ #
+
+ @property
+ def stages(self):
+ stages = StageOrder()
+
+ # FIXME: user_query calls task.choose_match, which calls session.choose_match.
+ # Better abstraction needed upstream.
+ stages.append(user_query(self))
+
+ # Early import stages
+ plugs = plugins.find_plugins()
+ for p in plugs:
+ for stage in p.get_early_import_stages():
+ stages.append(
+ plugin_stage(
+ self,
+ stage,
+ ProgressState(
+ Progress.EARLY_IMPORTING,
+ plugin_name=stage.__name__,
+ ),
+ ),
+ name=f"early_plugin_stage_{p.__class__.__name__}_{stage.__name__}",
+ )
+
+ # Import stages
+ for p in plugs:
+ for stage in p.get_import_stages():
+ stages.append(
+ plugin_stage(
+ self,
+ stage,
+ ProgressState(
+ Progress.IMPORTING,
+ plugin_name=stage.__name__,
+ ),
+ ),
+ name=f"plugin_stage_{p.__class__.__name__}_{stage.__name__}",
+ )
+
+ # finally, move files
+ stages.append(manipulate_files(self))
+ stages.append(finalize(self))
+
+ return stages
+
+ def finalize(self, task: importer.ImportTask):
+ """
+ Reset previous match threshold exceptions.
+
+ Needed because we might run a normal ImportSession manually after
+ the AutoImportSession. The AutoImportSession needs to keep an Exception in its
+ status to inform the frontend, which we need to clear here.
+ (No need to override `finalize` in AutoImportSession, despite it inherriting
+ from here, because it raises when below threshold).
+ """
+ if (
+ self.state.exc is not None
+ and self.state.exc["type"] == "NotImportedException"
+ and self.state.exc["message"].startswith("Match below threshold")
+ ):
+ log.debug(
+ "Clearing previous MatchThresholdException after successful import."
+ )
+ self.state.exc = None
+ super().finalize(task)
+
+ # --------------------------- Stage Definitions -------------------------- #
+
+ def choose_match(self, task: importer.ImportTask):
+ self.logger.setLevel(logging.DEBUG)
+ self.logger.debug(f"choose_match {task}")
+
+ task_state = self.state.get_task_state_for_task_raise(task)
+
+ # Pick the candidate to import
+ candidate_id = self.candidate_ids[task_state.id]
+
+ if isinstance(candidate_id, str):
+ candidate_state = task_state.get_candidate_state_by_id(candidate_id)
+ if candidate_state is None:
+ raise ValueError(f"Candidate with id {candidate_id} not found.")
+ elif candidate_id == CandidateChoiceFallback.BEST:
+ candidate_state = task_state.best_candidate_state
+ if candidate_state is None:
+ raise ValueError(f"No candidate found.")
+ elif candidate_id == CandidateChoiceFallback.ASIS:
+ candidate_state = task_state.asis_candidate
+ else:
+ raise NotImplementedError("ImportChoice.ASIS not implemented yet.")
+
+ # update task_state to keep track of the choice in the database
+ task_state.chosen_candidate_state_id = candidate_state.id
+ log.debug(
+ f"Setting chosen candidate for task {task_state.id} to {candidate_state.id}"
+ )
+
+ # Let plugins display info
+ results = plugins.send("import_task_before_choice", session=self, task=task)
+ actions = [action for action in results if action]
+
+ if len(actions) > 0:
+ # decide if we can just move past this and ignore the plugins
+ raise UserError(
+ f"Plugins returned actions, which is not supported for {self.__class__.__name__}"
+ )
+
+ # ASIS
+ if candidate_state.id == task_state.asis_candidate.id:
+ log.debug(f"Importing {task} as-is")
+ return importer.Action.ASIS
+
+ return candidate_state.match
+
+ def resolve_duplicate(
+ self, task: importer.ImportTask, found_duplicates: list[BeetsAlbum]
+ ):
+ log.debug(
+ f"Resolving duplicates for {task} with action {self.duplicate_actions}"
+ )
+
+ if len(found_duplicates) == 0:
+ log.debug(f"No duplicates found for")
+ return
+
+ task_state = self.state.get_task_state_for_task_raise(task)
+ task_duplicate_action = self.duplicate_actions[task_state.id]
+ task_state.duplicate_action = task_duplicate_action
+ match task_duplicate_action:
+ case "skip":
+ task.set_choice(importer.Action.SKIP)
+ case "keep":
+ pass
+ case "remove":
+ task.should_remove_duplicates = True
+ case "merge":
+ task.should_merge_duplicates = True
+ case "ask":
+ # task.set_choice(importer.action.SKIP)
+ raise DuplicateException(
+ "You have set the duplicate action to 'ask' in your beets config."
+ )
+ case _:
+ raise DuplicateException(
+ f"Unknown duplicate action: {self.duplicate_actions}"
+ )
+
+
+class BootlegImportSession(ImportSession):
+ """
+ Import session to import without modifying metadata.
+
+ No preview session required.
+
+ Essentially `beet import --group-albums -A`, ideal for bootlegs and
+ just getting a folder into your library where you are sure the metadata is correct.
+ """
+
+ def __init__(
+ self,
+ state,
+ config_overlay: dict | None = None,
+ **kwargs,
+ ):
+ """Create new ImportAsIsSession.
+
+ Parameters
+ ----------
+ state: SessionState
+ Preconfigured state of the import session.
+ config_overlay : dict
+ Configuration to overlay on top of the default config.
+ "import.group_albums", "import.autotag" and "import.search_ids" are ignored.
+ **kwargs
+ See `ImportSession`.
+ """
+
+ config_overlay = {} if config_overlay is None else deepcopy(config_overlay)
+ import_overlay = config_overlay.get("import", {})
+
+ if "group_albums" in import_overlay and import_overlay["group_albums"] is False:
+ log.warning("Overwriting 'group_albums' in config_overlay.")
+ if "autotag" in import_overlay and import_overlay["autotag"] is True:
+ log.warning("Overwriting 'autotag' in config_overlay.")
+ if "search_ids" in import_overlay:
+ log.warning("Overwriting 'search_ids' in config_overlay.")
+
+ import_overlay["group_albums"] = True
+ import_overlay["autotag"] = False
+ import_overlay["search_ids"] = None
+
+ config_overlay["import"] = import_overlay
+
+ super().__init__(state, config_overlay, **kwargs)
+
+ # overwrite the default action for all tasks
+ self.candidate_ids = parse_task_id_mapping(
+ {"*": CandidateChoiceFallback.ASIS},
+ CandidateChoiceFallback.ASIS,
+ )
+
+ @property
+ def stages(self):
+ stages = super().stages
+ stages.insert(before="user_query", stage=group_albums(self))
+ return stages
+
+
+class AutoImportSession(ImportSession):
+ """Generate a preview and import if the best match is good enough.
+
+ Preview generation is skipped if the provided session state already has a preview.
+
+ Wether the import is triggered depends on the specified `import_threshold`, or
+ the beets-config setting `match.strong_rec_thresh`.
+ The match quality is calculated via penalties, thus it ranges from 0 to 1, but a
+ perfect match is at 0. The same convention is used for thresholds.
+
+ The default threshold is 0.04, so that a "96% match or better" will be imported.
+
+ Raises a `NotImportedException` if the match quality is worse than the threshold,
+ stopping the pipeline.
+ """
+
+ import_threshold: float
+
+ def __init__(
+ self,
+ state,
+ config_overlay: dict | None = None,
+ import_threshold: float | None = None,
+ **kwargs,
+ ):
+ """Create new AutoImportSession.
+
+ Parameters
+ ----------
+ state: SessionState
+ Preconfigured state of the import session.
+ config_overlay : optional, dict
+ Configuration to overlay on top of the default config.
+ import_threshold: optional, float
+ 0 to import only perfect matches, 1 to import everything. Default is 0.04.
+ **kwargs
+ See `ImportSession`.
+ """
+
+ super().__init__(state, config_overlay, **kwargs)
+
+ if import_threshold is None:
+ self.import_threshold = self.get_config_value(
+ "match.strong_rec_thresh", float
+ )
+ else:
+ self.import_threshold = import_threshold
+
+ @property
+ def stages(self):
+ stages = super().stages
+ stages.insert(before="user_query", stage=match_threshold(self))
+ return stages
+
+ def match_threshold(self, task: importer.ImportTask):
+ """Check if the match quality is good enough to import.
+
+ Returns true if candidates were found, and the match quality is better than
+ threshlold.
+
+ Note: What stops the pipeline is that we set task.choice to importer.action.SKIP,
+ or raise an exception.
+
+ Currently raising, as we do not have a dedicated progress for "not imported".
+
+ FIXME: Instead of adding a whole new stage, with progress and a session function,
+ we could simply extend the choose_match function. It's defined for the normal
+ import session, and gets called early in user_query.
+ (see #78)
+ """
+ try:
+ task_state = self.state.get_task_state_for_task(task)
+ distance = float(task_state.best_candidate_state.distance) # type: ignore
+ except (AttributeError, TypeError):
+ distance = 2.0
+
+ if len(task.candidates) == 0:
+ raise NoCandidatesFoundException()
+
+ if distance > self.import_threshold:
+ log.debug(
+ f"Best candidate was worse than threshold {distance=} {self.import_threshold=}"
+ )
+ d = (1 - distance) * 100
+ t = (1 - self.import_threshold) * 100
+ raise NotImportedException(f"Match below threshold ({d:.0f}% < {t:.0f}%)")
+ # beets would handle this via the task action:
+ task.set_choice(importer.action.SKIP)
+ else:
+ log.info(
+ f"Best candidate was better than threshold, importing to library. {distance=} {self.import_threshold=}"
+ )
+
+
+class UndoSession(BaseSession):
+ delete_files: bool
+
+ def __init__(
+ self,
+ state: SessionState,
+ config_overlay: dict | None = None,
+ delete_files: bool = False,
+ **kwargs,
+ ):
+ super().__init__(state, config_overlay, **kwargs)
+ self.delete_files = delete_files
+
+ async def run_async(self) -> SessionState:
+ """Undo an import.
+
+ A bit of a hack to reuse the BaseSession as we do
+ not operate on tasks here but makes things easier in
+ calling this in the invoker.
+
+ Note: this Session should only be run in the (single-thread) import queue,
+ because we want to limit beets-library and file interactions to be serial
+ to avoid conflicts.
+ """
+
+ if self.state.progress != Progress.IMPORT_COMPLETED:
+ log.error(
+ f"Cannot undo import from state {self.state.progress}. "
+ + "Only imports can be undone."
+ )
+ e = UserError(
+ "Cannot undo if never imported! You need to import to undo first."
+ )
+ self.state.exc = to_serialized_exception(e)
+ raise e
+
+ for t_state in self.state.task_states:
+ t_state.set_progress(Progress.DELETING)
+
+ # Revert the movement of items that beets does during manipulate_files()
+ # TODO: support Move operations (currently we only allow copy)
+
+ # HACK: To allow an reimport after an undo, we need to use the old
+ # paths of the items. This is a bit hacky, but We did not find
+ # a better way to do this while maintaining the original beets logic.
+ # -> the old_paths attribute of a beets_task is set during task.manipulate_files()
+ # Unfortunately, here task.imported_items() does not work yet (.match not set)
+
+ for t_state in self.state.task_states:
+ chosen_candidate = t_state.chosen_candidate_state
+ if chosen_candidate is None:
+ raise ValueError("No chosen candidate found for task.")
+ if t_state.task.old_paths is None:
+ raise ValueError("No old paths found for task.")
+ for idx, item in enumerate(chosen_candidate.match.mapping.keys()): # type: ignore
+ # yes, keys are the items and need to be updated!
+ # mapping maps objects to objects.
+ item.path = t_state.task.old_paths[idx]
+
+ # Delete files
+ excs: list[Exception] = []
+ pths: list[Path] = []
+ for t_state in self.state.task_states:
+ try:
+ delete_from_beets(t_state.id, self.delete_files, self.lib)
+ except Exception as e:
+ items = self.lib.items(f"gui_import_id:{t_state.id}")
+
+ excs.append(e)
+ pths.extend([Path(item.path.decode("utf-8")) for item in items])
+
+ if len(excs) > 0:
+ for exc in excs:
+ log.exception(exc)
+
+ # FIXME: Multi/array exceptions need handling
+ self.state.exc = to_serialized_exception(excs[0])
+ raise IntegrityException(
+ "Could not delete all items. Some items might be left in the library. "
+ + f"Problematic files were: {pths}"
+ )
+
+ # Update our state and progress
+ for t_state in self.state.task_states:
+ t_state.set_progress(Progress.DELETION_COMPLETED)
+
+ # Send cli_exit event to have compatibility with beets plugin that
+ # execute after the import is completed.
+ plugins.send("cli_exit", lib=self.lib)
+
+ return self.state
+
+ @property
+ def stages(self):
+ # This tweaked session skips the pipeline
+ return StageOrder()
+
+
+## Edge cases
+# 1. Session doesn't exist anymore -> delete
+# 2. Session exists but multiple tasks -> undo (session)
+# 3. Session exists but file do not anymore in import folder -> undo, warnings
+
+# Flow:
+# - remove from beets db
+# - revert our session (if possible)
+
+
+def delete_from_beets(task_id: str, delete_files: bool, lib: BeetsLibrary):
+ """Low-level, delete the items from the beets library."""
+
+ # We set a gui_import_id in the beets database this is equal to the session id
+ # see _apply_choice in stages.py
+
+ items = lib.items(f"gui_import_id:{task_id}")
+
+ if len(items) == 0:
+ raise ValueError("No items found that match this import session id.")
+
+ with lib.transaction():
+ for item in items:
+ item.remove(delete_files)
diff --git a/backend/beets_flask/importer/stages.py b/backend/beets_flask/importer/stages.py
index ab499b2d..ac978a87 100644
--- a/backend/beets_flask/importer/stages.py
+++ b/backend/beets_flask/importer/stages.py
@@ -1,694 +1,694 @@
-"""Beets-flask extends every stage in the normal beets import session.
-
-This allows us to keep track of the import state and communicate it to the frontend.
-"""
-
-from __future__ import annotations
-
-import itertools
-from collections.abc import Callable, Generator
-from datetime import datetime
-from functools import wraps
-from inspect import isgenerator
-from typing import (
- TYPE_CHECKING,
- Any,
- Literal,
- TypeVar,
- TypeVarTuple,
- cast,
-)
-
-from beets import library, plugins
-from beets.importer import (
- Action,
- ImportTask,
- SentinelImportTask,
-)
-from beets.importer.stages import (
- _extend_pipeline,
- _freshen_items,
-)
-from beets.importer.tasks import ImportTaskFactory
-from beets.util import MoveOperation, bytestring_path, displayable_path
-from beets.util import pipeline as beets_pipeline
-
-from beets_flask import log
-from beets_flask.server.exceptions import (
- NoCandidatesFoundException,
- NotImportedException,
-)
-
-from .progress import Progress, ProgressState
-from .types import BeetsImportTask
-
-if TYPE_CHECKING:
- from beets_flask.importer.session import (
- AutoImportSession,
- BaseSession,
- ImportSession,
- )
- from beets_flask.importer.types import BeetsItem
-
- from .pipeline import Stage
-
- # Tell type-checking that subclasses of BaseSession are allowed
- Session = TypeVar("Session", bound=BaseSession)
-
-
-def skip_until(
- progress: Progress,
-) -> Callable[
- [Callable[[Session, Task, *Arg], Ret]],
- Callable[[Session, Task, *Arg], Ret | Task],
-]:
- """Decorater to skip all tasks with progress lower than the desired one.
-
- Needed to resume up a session without re-doing everything thats already contained
- in the state.
-
- Usage
- -----
- ```python
- @skip_until(Progress.READING_FILES)
- def read_task(session: BaseSessionNew, task: ImportTask):
- pass
- ```
- """
-
- def decorator(
- func: Callable[[Session, Task, *Arg], Ret],
- ) -> Callable[[Session, Task, *Arg], Ret | Task]:
- @wraps(func)
- def wrapper(session: Session, task: Task, *args: *Arg) -> Ret | Task:
- # Skip automatically if the task is already progressed
- # Note that >= and > below both have caveats:
- # >= might give us a too optimistic state if the function call fails
- # > might re-run the function if we resume, and we have not testet
- # what happens when doing the same state-mutation twice
- if not isinstance(task, ImportTask):
- log.warning(
- f"Skipping {progress} for {task} as it is not an ImportTask"
- )
- return task
-
- # We set the progress before the stage runs, thus we need to compare
- # with - and redo - the previous progress.
- # Otherwise, we couldn't retry a stage once it crashed or was aborted.
- # For jobs that did not fail, we have extra progress levels (dummies) that
- # do not need any compute to redo. They are the most common point to resume
- # from (PREVIEW_COMPLETED in particular).
- prev_progress = session.state.upsert_task(task).progress
- if prev_progress > progress:
- log.debug(
- f"Skipping {progress} for {task} because task progress {prev_progress=}"
- )
- return task
-
- log.debug(f"Skipped until encountered {progress} for {task}")
- return func(session, task, *args)
-
- return wrapper
-
- return decorator
-
-
-def set_progress(
- before: Progress,
- after: Progress | None = None,
- on_error: dict[type[Exception], Progress] = {},
-) -> Callable[
- [Callable[[Session, Task, *Arg], Ret]],
- Callable[[Session, Task, *Arg], Ret | Task],
-]:
- """Decorater to set the progress of a task.
-
- Basically calls
- `session.set_progress(task, progress)`
- before the decorated function.
-
- Optionally, if an Exception is encountered while processing the task,
- another progress can be set (to account for later pipeline-steps, which will not
- take place). By default, we still raise.
-
- When combining, use after `skip_until()` decorator.
-
- Parameters
- ----------
- before: Progress
- Progress to set when (before) processing the task.
- after: Progress, optional
- Progress to set after processing the task. By default `before` is kept.
- on_error: dict[type[Exception], Progress], optional
- Mapping, which Progress to set for which Exception (instance check on the
- Exception). By default, if an exception occurs, the `before` Progress is kept.
-
- Usage
- -----
- ```python
- @set_progress(Progress.READING_FILES)
- def read_task(session: BaseSessionNew, task: ImportTask):
- pass
-
- @set_progress(
- Progress.LOOKING_UP_CANDIDATES,
- on_Error={NoCandidatesFoundException: Progress.PREVIEW_COMPLETED}
- )
- def lookup_candidates(session: BaseSessionNew, task: ImportTask):
- pass
- ```
- """
-
- def decorator(
- func: Callable[[Session, Task, *Arg], Ret],
- ) -> Callable[[Session, Task, *Arg], Ret | Task]:
- @wraps(func)
- def wrapper(session: Session, task: Task, *args: *Arg) -> Ret | Task:
- log.debug(f"Setting progress {before=} for {task}")
-
- session.set_task_progress(task, before)
- try:
- res = func(session, task, *args)
- if after is not None:
- log.debug(f"Setting progress {after=} for {task}")
- session.set_task_progress(task, after)
- return res
- except Exception as e:
- for e_to_handle, p in on_error.items():
- if isinstance(e, e_to_handle):
- log.debug(
- f"Setting progress to {p} after Exception for {task} {e}"
- )
- session.set_task_progress(task, p)
- raise
-
- return wrapper
-
- return decorator
-
-
-class StageOrder(dict):
- """An ordered dict of stages, mapping the stage name to its generator.
-
- Returned by each sessions `.stages` property.
- """
-
- def append(
- self,
- stage: Stage[ImportTask, Any],
- name: str | None = None,
- ):
- """Append a stage to the Order."""
-
- name = name or str(getattr(stage, "__name__", f"unknown_stage"))
- if name in self.keys():
- raise ValueError(f"Stage with name {name} already exists.")
-
- self[name] = stage
-
- def insert(
- self,
- stage: Stage[ImportTask, Any],
- name: str | None = None,
- after: str | None = None,
- before: str | None = None,
- ):
- """Insert a stage after or before another specific stage."""
-
- if after is None and before is None or (after and before):
- raise ValueError("Either `after` or `before` must be specified.")
-
- name = name or str(getattr(stage, "__name__", f"unknown_stage"))
- if name in self.keys():
- raise ValueError(f"Stage with name {name} already exists.")
-
- # even for the OrderedDict there is no insert at index method, so just rebuild
- keys = list(self.keys())
- values = list(self.values())
-
- idx = keys.index(after or before) # type: ignore
- if after:
- idx += 1
- keys.insert(idx, name)
- values.insert(idx, stage)
-
- self.clear()
- self.update(zip(keys, values))
-
-
-# --------------------------------- Decorator -------------------------------- #
-
-Arg = TypeVarTuple("Arg") # args und kwargs
-Ret = TypeVar("Ret") # return type
-Task = TypeVar(
- "Task",
- bound=BeetsImportTask,
-) # task
-
-
-def stage(
- func: Callable[[*Arg, Task], Ret | None],
-):
- """Decorate a function to become a simple stage.
-
- Yields a task and waits until the next task is sent to it.
-
- >>> @stage
- ... def add(n, i):
- ... return i + n
- >>> pipe = Pipeline([
- ... iter([1, 2, 3]),
- ... add(2),
- ... ])
- >>> list(pipe.pull())
- [3, 4, 5]
- """
-
- # use the wraps decorator to lift function name etc, we use this in StageOrder class
- @wraps(func)
- def coro(
- *args: *Arg,
- ) -> Generator[Ret | Task | None, Task, None]:
- # in some edge-cases we get no task. thus, we have to include the generic R
- task: Task | Ret | Generator[Task] | None = None
- while True:
- if isgenerator(task):
- for t in task:
- task = yield t
- else:
- task = yield cast(
- Task | None | Ret, task
- ) # wait for send to arrive. the first next() always returns None
- # yield task, call func which gives new task, yield new task in next()
- task = cast(Task, task) # Slightly hacky, but we know task is a Task here
- task = func(*(args + (task,)))
-
- return coro
-
-
-def mutator_stage(
- func: Callable[[*Arg, Task], Ret], name: str | None = None
-) -> Callable[[*Arg], Generator[Ret | Task | None, Task, None]]:
- """Decorate a function that manipulates items in a coroutine to become a simple stage.
-
- Yields a task and waits until the next task is sent to it.
-
- >>> @mutator_stage
- ... def setkey(key, item):
- ... item[key] = True
- >>> pipe = Pipeline([
- ... iter([{'x': False}, {'a': False}]),
- ... setkey('x'),
- ... ])
- >>> list(pipe.pull())
- [{'x': True}, {'a': False, 'x': True}]
- """
-
- @wraps(func)
- def coro(
- *args: *Arg,
- ) -> Generator[Ret | Task | None, Task, None]:
- task = None
- while True:
- task = yield task # wait for send to arrive. the first next() always returns None
- # perform function on task, and in next() send the same, modified task
- # funcs prob. modify task in place?
- func(*(args + (task,)))
-
- return coro
-
-
-# --------------------------------- Producer --------------------------------- #
-
-
-def read_tasks(
- session: BaseSession,
-):
- """Read the files from the paths and generate tasks.
-
- If the session already has tasks yield them.
-
- Adapted closely from beets, but we do not need/support resuming and skipping
- """
-
- log.debug(f"Reading files")
-
- # Our Skip-check usually uses Progress, but here we do not have progress yet
- # We want to catch the case when we resume a session,
- # where we manually add old tasks from disk.
- if len(session.state.tasks) > 0:
- for task in session.state.tasks:
- yield task
- return
-
- for toppath in session.paths:
- task_factory = ImportTaskFactory(toppath, session)
- for task in task_factory.tasks():
- if isinstance(task, SentinelImportTask):
- log.debug(f"Skipping {displayable_path(toppath)}")
- continue
-
- task_state = session.state.upsert_task(task)
- log.debug(f"Reading files from {displayable_path(toppath)}")
- task_state.set_progress(Progress.READING_FILES)
- yield task
-
- if not task_factory.imported:
- log.warning(f"No files imported from {displayable_path(toppath)}")
-
-
-# ----------------------------- Transform Stages ----------------------------- #
-
-
-def __group(item: library.Item) -> tuple[str, str]:
- return (item.albumartist or item.artist, item.album)
-
-
-@stage
-@skip_until(Progress.GROUPING_ALBUMS)
-@set_progress(Progress.GROUPING_ALBUMS)
-def group_albums(
- session: BaseSession,
- task: ImportTask,
-) -> beets_pipeline.MultiMessage:
- """
- Groups items of the task into albums using their metadata.
-
- The groups are identified using artist and album fields.
-
- Creates a new Task for each found album, and removes the old task (and task_state)
- from the session.
- """
-
- # Split old task into multiple, and only keep new ones!
- session.state.remove_task(task)
-
- tasks: list[ImportTask] = []
- sorted_items = sorted(task.items, key=__group)
- for _, _items in itertools.groupby(sorted_items, __group):
- items = list(_items) # Consume the iterator to a list
- task = ImportTask(task.toppath, [i.path for i in items], items)
- tasks += task.handle_created(session)
-
- # handle the beets-flask specific states
- for task in tasks:
- session.state.upsert_task(task)
-
- # FIXME: Not really sure we need this tbh, see also task.skip
- # tasks.append(SentinelImportTask(task.toppath, task.paths))
-
- return beets_pipeline.MultiMessage(tasks)
-
-
-@mutator_stage
-@skip_until(Progress.LOOKING_UP_CANDIDATES)
-@set_progress(
- Progress.LOOKING_UP_CANDIDATES,
- on_error={NoCandidatesFoundException: Progress.PREVIEW_COMPLETED},
-)
-def lookup_candidates(
- session: BaseSession,
- task: ImportTask,
-):
- """Performing the initial MusicBrainz lookup for an album.
-
- We tweaks this from upstream beets to not
- call `task.lookup_candidates()` but instead `session.lookup_candidates(task)`,
- with some extra logic
-
- This is more consistent, as it allows the logic
- to be modified by each kind of session.
-
- Calls `task.lookup_candidates()`,
- which sets attributes of the task:
- - cur_artist # metadata in file
- - cur_album
- - candidates
- - rec
- """
- if task.skip:
- # FIXME This gets duplicated a lot. We need a better
- # abstraction.
- return
-
- # FIXME: what happens with our new skip logic and plugins?
- plugins.send("import_task_start", session=session, task=task)
-
- session.lookup_candidates(task)
-
-
-@mutator_stage
-@skip_until(Progress.IDENTIFYING_DUPLICATES)
-@set_progress(before=Progress.IDENTIFYING_DUPLICATES, after=Progress.PREVIEW_COMPLETED)
-def identify_duplicates(
- session: BaseSession,
- task: ImportTask,
-):
- """Stage to identify which candidates would be duplicates if imported."""
- if task.skip:
- return
-
- session.identify_duplicates(task)
-
-
-@stage
-@skip_until(Progress.WAITING_FOR_USER_SELECTION)
-@set_progress(Progress.WAITING_FOR_USER_SELECTION)
-def user_query(
- session: ImportSession,
- task: ImportTask,
-) -> ImportTask | beets_pipeline.MultiMessage | Literal["__PIPELINE_BUBBLE__"]:
- """A coroutine for interfacing with the user about the tagging process.
-
- The coroutine accepts an ImportTask objects. It uses the
- session's `choose_match` method to determine the `action` for
- this task. Depending on the action additional stages are executed
- and the processed task is yielded.
-
- It emits the ``import_task_choice`` event for plugins. Plugins have
- access to the choice via the ``task.choice_flag`` property and may
- choose to change it.
- """
-
- if task.skip:
- return task
-
- if session.already_merged(task.paths):
- # For some reason the type hinter does not like this without cast
- return cast(Literal["__PIPELINE_BUBBLE__"], beets_pipeline.BUBBLE)
-
- # Ask the user for a choice.
- # This calls session.choose_match(task)... but whyyy?
- task.choose_match(session)
- plugins.send("import_task_choice", session=session, task=task)
-
- # TODO: Import as singletons i.e. task.choice_flag is action.TRACKS
-
- # As albums: group items by albums and create task for each album
- if task.choice_flag is Action.ALBUMS:
- log.warning("This should never run via beets-flask!")
- return _extend_pipeline(
- [task],
- group_albums(session),
- lookup_candidates(session),
- user_query(session),
- )
-
- # Note: this checks the global config for the default action.
- found_duplicates = task.find_duplicates(session.lib)
- # Different than vanilla beets, we do not use a stage for duplicate resolution,
- # as it only calls the sessions method, anyway.
- session.resolve_duplicate(task, found_duplicates)
-
- if task.should_merge_duplicates:
- # Create a new task for tagging the current items
- # and duplicates together
- duplicate_items = task.duplicate_items(session.lib)
-
- # Duplicates would be reimported so make them look "fresh"
- _freshen_items(duplicate_items)
- duplicate_paths = [item.path for item in duplicate_items]
-
- # Record merged paths in the session so they are not reimported
- session.mark_merged(duplicate_paths)
-
- merged_task = ImportTask(
- None, task.paths + duplicate_paths, task.items + duplicate_items
- )
- return _extend_pipeline(
- [merged_task], lookup_candidates(session), user_query(session)
- )
-
- _apply_choice(session, task)
-
- return task
-
-
-# Dynamicly set_progress for plugin name
-# e.g. DetailedProgress(Progress.EARLY_IMPORT, plugin_name="my_plugin")
-@mutator_stage
-def plugin_stage(
- session: BaseSession,
- func: Callable[[BaseSession, ImportTask], None],
- progress: ProgressState,
- task: ImportTask,
-):
- # TODO: Skip if already progressed
- session.set_task_progress(task, progress)
- if task.skip:
- return
-
- log.debug(f"Running plugin stage {func.__name__}")
- func(session, task)
- log.debug(f"Finished plugin stage {func.__name__}")
-
- task.reload()
- log.debug(f"Reload plugin stage done {func.__name__}")
-
-
-@mutator_stage
-@skip_until(Progress.MATCH_THRESHOLD)
-@set_progress(
- Progress.MATCH_THRESHOLD,
- on_error={NotImportedException: Progress.PREVIEW_COMPLETED},
-)
-def match_threshold(
- session: AutoImportSession,
- task: ImportTask,
-):
- """Stage to determine if a match is good enough to be auto-imported."""
- if task.skip:
- log.debug(f"Skipping task: {session=}, {task=}")
- return task
-
- session.match_threshold(task)
-
-
-# --------------------------------- Consumer --------------------------------- #
-
-
-@stage
-@skip_until(Progress.MANIPULATING_FILES)
-@set_progress(before=Progress.MANIPULATING_FILES, after=Progress.IMPORT_COMPLETED)
-def manipulate_files(
- session: BaseSession,
- task: ImportTask,
-) -> ImportTask:
- """A coroutine (pipeline stage) that performs necessary file.
-
- manipulations *after* items have been added to the library and
- finalizes each task.
- """
- if not task.skip:
- if task.should_remove_duplicates:
- task.remove_duplicates(session.lib)
-
- if session.config["copy"]:
- operation = MoveOperation.COPY
- # elif session.config["move"]:
- # operation = MoveOperation.MOVE
- # elif session.config["link"]:
- # operation = MoveOperation.LINK
- # elif session.config["hardlink"]:
- # operation = MoveOperation.HARDLINK
- # elif session.config["reflink"] == "auto":
- # operation = MoveOperation.REFLINK_AUTO
- # elif session.config["reflink"]:
- # operation = MoveOperation.REFLINK
- # else:
- # operation = None
- else:
- log.warning(
- "Beets-flask does not yet support other import modes than 'copy'. "
- + "Please consider updating your config."
- )
- operation = MoveOperation.COPY
-
- task.manipulate_files(session, operation, write=session.config["write"])
-
- # Progress, cleanup, and event.
- task.finalize(session)
-
- # Cleanup of duplicates. After successful import, we do not want the candidates
- # to have duplicate-info anymore. The reason is that if we undo the import,
- # there wont be any duplicates in the library. Also, we dont want to show
- # the duplicate-badge for things that were imported.
- # TODO: outlook: add new badges for what has been done with duplicates?
- task_state = session.state.get_task_state_for_task_raise(task)
- chosen_candidate = task_state.chosen_candidate_state
- if chosen_candidate:
- chosen_candidate.duplicate_ids = []
- # We remove duplicate info from all candidates.
- # Although this causes the frontend to possibly not show the duplicate
- # actions, that is fine: Upon re-import, we raise a duplicate action error,
- # which is shown to the user, and then the duplicate actions will appear.
-
- if task_state.toppath is not None and len(session.state.task_states) == 1:
- # If we have only one task and no toppath this indicates that we are
- # operating on a temp directory we override the toppath
- # to the session's folder path.
- task.toppath = bytestring_path(session.state.folder_path)
-
- return task
-
-
-@stage
-def finalize(
- session: BaseSession,
- task: ImportTask,
-) -> ImportTask:
- """Give our custom sessions a way to do something at the very end."""
- session.finalize(task)
- return task
-
-
-def _apply_choice(session: ImportSession, task: ImportTask):
- # tweaked version of beets apply_choices.
- # we do not want to rely on the global config object
- # (in particular, the set_fields, because we always want to set gui_import_id).
- # see the original:
- # from beets.importer import apply_choice
-
- if task.skip:
- return
-
- # Change metadata.
- if task.apply:
- task.apply_metadata()
- plugins.send("import_task_apply", session=session, task=task)
-
- # We need to reinsert items into the db if they were removed earlier
- item: BeetsItem
- for item in task.imported_items():
- item._db = session.lib
- item.album_id = None
- item.id = None
-
- task.add(session.lib)
- task.set_fields(session.lib)
-
- # copy of core logic from set_fields()
- items: list[BeetsItem] = task.imported_items()
-
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
- task_state = session.state.get_task_state_for_task_raise(task)
- with session.lib.transaction():
- for item in items:
- item.set_parse("gui_import_id", task_state.id)
- item.set_parse("gui_import_date", timestamp)
- item.store()
- task.album.set_parse("gui_import_id", task_state.id)
- task.album.set_parse("gui_import_date", timestamp)
- task.album.store()
-
-
-__all__ = [
- "read_tasks",
- "group_albums",
- "lookup_candidates",
- "identify_duplicates",
- "user_query",
- "plugin_stage",
- "manipulate_files",
- "mark_tasks_completed",
-]
+"""Beets-flask extends every stage in the normal beets import session.
+
+This allows us to keep track of the import state and communicate it to the frontend.
+"""
+
+from __future__ import annotations
+
+import itertools
+from collections.abc import Callable, Generator
+from datetime import datetime
+from functools import wraps
+from inspect import isgenerator
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ Literal,
+ TypeVar,
+ TypeVarTuple,
+ cast,
+)
+
+from beets import library, plugins
+from beets.importer import (
+ Action,
+ ImportTask,
+ SentinelImportTask,
+)
+from beets.importer.stages import (
+ _extend_pipeline,
+ _freshen_items,
+)
+from beets.importer.tasks import ImportTaskFactory
+from beets.util import MoveOperation, bytestring_path, displayable_path
+from beets.util import pipeline as beets_pipeline
+
+from beets_flask import log
+from beets_flask.server.exceptions import (
+ NoCandidatesFoundException,
+ NotImportedException,
+)
+
+from .progress import Progress, ProgressState
+from .types import BeetsImportTask
+
+if TYPE_CHECKING:
+ from beets_flask.importer.session import (
+ AutoImportSession,
+ BaseSession,
+ ImportSession,
+ )
+ from beets_flask.importer.types import BeetsItem
+
+ from .pipeline import Stage
+
+ # Tell type-checking that subclasses of BaseSession are allowed
+ Session = TypeVar("Session", bound=BaseSession)
+
+
+def skip_until(
+ progress: Progress,
+) -> Callable[
+ [Callable[[Session, Task, *Arg], Ret]],
+ Callable[[Session, Task, *Arg], Ret | Task],
+]:
+ """Decorater to skip all tasks with progress lower than the desired one.
+
+ Needed to resume up a session without re-doing everything thats already contained
+ in the state.
+
+ Usage
+ -----
+ ```python
+ @skip_until(Progress.READING_FILES)
+ def read_task(session: BaseSessionNew, task: ImportTask):
+ pass
+ ```
+ """
+
+ def decorator(
+ func: Callable[[Session, Task, *Arg], Ret],
+ ) -> Callable[[Session, Task, *Arg], Ret | Task]:
+ @wraps(func)
+ def wrapper(session: Session, task: Task, *args: *Arg) -> Ret | Task:
+ # Skip automatically if the task is already progressed
+ # Note that >= and > below both have caveats:
+ # >= might give us a too optimistic state if the function call fails
+ # > might re-run the function if we resume, and we have not testet
+ # what happens when doing the same state-mutation twice
+ if not isinstance(task, ImportTask):
+ log.warning(
+ f"Skipping {progress} for {task} as it is not an ImportTask"
+ )
+ return task
+
+ # We set the progress before the stage runs, thus we need to compare
+ # with - and redo - the previous progress.
+ # Otherwise, we couldn't retry a stage once it crashed or was aborted.
+ # For jobs that did not fail, we have extra progress levels (dummies) that
+ # do not need any compute to redo. They are the most common point to resume
+ # from (PREVIEW_COMPLETED in particular).
+ prev_progress = session.state.upsert_task(task).progress
+ if prev_progress > progress:
+ log.debug(
+ f"Skipping {progress} for {task} because task progress {prev_progress=}"
+ )
+ return task
+
+ log.debug(f"Skipped until encountered {progress} for {task}")
+ return func(session, task, *args)
+
+ return wrapper
+
+ return decorator
+
+
+def set_progress(
+ before: Progress,
+ after: Progress | None = None,
+ on_error: dict[type[Exception], Progress] = {},
+) -> Callable[
+ [Callable[[Session, Task, *Arg], Ret]],
+ Callable[[Session, Task, *Arg], Ret | Task],
+]:
+ """Decorater to set the progress of a task.
+
+ Basically calls
+ `session.set_progress(task, progress)`
+ before the decorated function.
+
+ Optionally, if an Exception is encountered while processing the task,
+ another progress can be set (to account for later pipeline-steps, which will not
+ take place). By default, we still raise.
+
+ When combining, use after `skip_until()` decorator.
+
+ Parameters
+ ----------
+ before: Progress
+ Progress to set when (before) processing the task.
+ after: Progress, optional
+ Progress to set after processing the task. By default `before` is kept.
+ on_error: dict[type[Exception], Progress], optional
+ Mapping, which Progress to set for which Exception (instance check on the
+ Exception). By default, if an exception occurs, the `before` Progress is kept.
+
+ Usage
+ -----
+ ```python
+ @set_progress(Progress.READING_FILES)
+ def read_task(session: BaseSessionNew, task: ImportTask):
+ pass
+
+ @set_progress(
+ Progress.LOOKING_UP_CANDIDATES,
+ on_Error={NoCandidatesFoundException: Progress.PREVIEW_COMPLETED}
+ )
+ def lookup_candidates(session: BaseSessionNew, task: ImportTask):
+ pass
+ ```
+ """
+
+ def decorator(
+ func: Callable[[Session, Task, *Arg], Ret],
+ ) -> Callable[[Session, Task, *Arg], Ret | Task]:
+ @wraps(func)
+ def wrapper(session: Session, task: Task, *args: *Arg) -> Ret | Task:
+ log.debug(f"Setting progress {before=} for {task}")
+
+ session.set_task_progress(task, before)
+ try:
+ res = func(session, task, *args)
+ if after is not None:
+ log.debug(f"Setting progress {after=} for {task}")
+ session.set_task_progress(task, after)
+ return res
+ except Exception as e:
+ for e_to_handle, p in on_error.items():
+ if isinstance(e, e_to_handle):
+ log.debug(
+ f"Setting progress to {p} after Exception for {task} {e}"
+ )
+ session.set_task_progress(task, p)
+ raise
+
+ return wrapper
+
+ return decorator
+
+
+class StageOrder(dict):
+ """An ordered dict of stages, mapping the stage name to its generator.
+
+ Returned by each sessions `.stages` property.
+ """
+
+ def append(
+ self,
+ stage: Stage[ImportTask, Any],
+ name: str | None = None,
+ ):
+ """Append a stage to the Order."""
+
+ name = name or str(getattr(stage, "__name__", f"unknown_stage"))
+ if name in self.keys():
+ raise ValueError(f"Stage with name {name} already exists.")
+
+ self[name] = stage
+
+ def insert(
+ self,
+ stage: Stage[ImportTask, Any],
+ name: str | None = None,
+ after: str | None = None,
+ before: str | None = None,
+ ):
+ """Insert a stage after or before another specific stage."""
+
+ if after is None and before is None or (after and before):
+ raise ValueError("Either `after` or `before` must be specified.")
+
+ name = name or str(getattr(stage, "__name__", f"unknown_stage"))
+ if name in self.keys():
+ raise ValueError(f"Stage with name {name} already exists.")
+
+ # even for the OrderedDict there is no insert at index method, so just rebuild
+ keys = list(self.keys())
+ values = list(self.values())
+
+ idx = keys.index(after or before) # type: ignore
+ if after:
+ idx += 1
+ keys.insert(idx, name)
+ values.insert(idx, stage)
+
+ self.clear()
+ self.update(zip(keys, values))
+
+
+# --------------------------------- Decorator -------------------------------- #
+
+Arg = TypeVarTuple("Arg") # args und kwargs
+Ret = TypeVar("Ret") # return type
+Task = TypeVar(
+ "Task",
+ bound=BeetsImportTask,
+) # task
+
+
+def stage(
+ func: Callable[[*Arg, Task], Ret | None],
+):
+ """Decorate a function to become a simple stage.
+
+ Yields a task and waits until the next task is sent to it.
+
+ >>> @stage
+ ... def add(n, i):
+ ... return i + n
+ >>> pipe = Pipeline([
+ ... iter([1, 2, 3]),
+ ... add(2),
+ ... ])
+ >>> list(pipe.pull())
+ [3, 4, 5]
+ """
+
+ # use the wraps decorator to lift function name etc, we use this in StageOrder class
+ @wraps(func)
+ def coro(
+ *args: *Arg,
+ ) -> Generator[Ret | Task | None, Task, None]:
+ # in some edge-cases we get no task. thus, we have to include the generic R
+ task: Task | Ret | Generator[Task] | None = None
+ while True:
+ if isgenerator(task):
+ for t in task:
+ task = yield t
+ else:
+ task = yield cast(
+ Task | None | Ret, task
+ ) # wait for send to arrive. the first next() always returns None
+ # yield task, call func which gives new task, yield new task in next()
+ task = cast(Task, task) # Slightly hacky, but we know task is a Task here
+ task = func(*(args + (task,)))
+
+ return coro
+
+
+def mutator_stage(
+ func: Callable[[*Arg, Task], Ret], name: str | None = None
+) -> Callable[[*Arg], Generator[Ret | Task | None, Task, None]]:
+ """Decorate a function that manipulates items in a coroutine to become a simple stage.
+
+ Yields a task and waits until the next task is sent to it.
+
+ >>> @mutator_stage
+ ... def setkey(key, item):
+ ... item[key] = True
+ >>> pipe = Pipeline([
+ ... iter([{'x': False}, {'a': False}]),
+ ... setkey('x'),
+ ... ])
+ >>> list(pipe.pull())
+ [{'x': True}, {'a': False, 'x': True}]
+ """
+
+ @wraps(func)
+ def coro(
+ *args: *Arg,
+ ) -> Generator[Ret | Task | None, Task, None]:
+ task = None
+ while True:
+ task = yield task # wait for send to arrive. the first next() always returns None
+ # perform function on task, and in next() send the same, modified task
+ # funcs prob. modify task in place?
+ func(*(args + (task,)))
+
+ return coro
+
+
+# --------------------------------- Producer --------------------------------- #
+
+
+def read_tasks(
+ session: BaseSession,
+):
+ """Read the files from the paths and generate tasks.
+
+ If the session already has tasks yield them.
+
+ Adapted closely from beets, but we do not need/support resuming and skipping
+ """
+
+ log.debug(f"Reading files")
+
+ # Our Skip-check usually uses Progress, but here we do not have progress yet
+ # We want to catch the case when we resume a session,
+ # where we manually add old tasks from disk.
+ if len(session.state.tasks) > 0:
+ for task in session.state.tasks:
+ yield task
+ return
+
+ for toppath in session.paths:
+ task_factory = ImportTaskFactory(toppath, session)
+ for task in task_factory.tasks():
+ if isinstance(task, SentinelImportTask):
+ log.debug(f"Skipping {displayable_path(toppath)}")
+ continue
+
+ task_state = session.state.upsert_task(task)
+ log.debug(f"Reading files from {displayable_path(toppath)}")
+ task_state.set_progress(Progress.READING_FILES)
+ yield task
+
+ if not task_factory.imported:
+ log.warning(f"No files imported from {displayable_path(toppath)}")
+
+
+# ----------------------------- Transform Stages ----------------------------- #
+
+
+def __group(item: library.Item) -> tuple[str, str]:
+ return (item.albumartist or item.artist, item.album)
+
+
+@stage
+@skip_until(Progress.GROUPING_ALBUMS)
+@set_progress(Progress.GROUPING_ALBUMS)
+def group_albums(
+ session: BaseSession,
+ task: ImportTask,
+) -> beets_pipeline.MultiMessage:
+ """
+ Groups items of the task into albums using their metadata.
+
+ The groups are identified using artist and album fields.
+
+ Creates a new Task for each found album, and removes the old task (and task_state)
+ from the session.
+ """
+
+ # Split old task into multiple, and only keep new ones!
+ session.state.remove_task(task)
+
+ tasks: list[ImportTask] = []
+ sorted_items = sorted(task.items, key=__group)
+ for _, _items in itertools.groupby(sorted_items, __group):
+ items = list(_items) # Consume the iterator to a list
+ task = ImportTask(task.toppath, [i.path for i in items], items)
+ tasks += task.handle_created(session)
+
+ # handle the beets-flask specific states
+ for task in tasks:
+ session.state.upsert_task(task)
+
+ # FIXME: Not really sure we need this tbh, see also task.skip
+ # tasks.append(SentinelImportTask(task.toppath, task.paths))
+
+ return beets_pipeline.MultiMessage(tasks)
+
+
+@mutator_stage
+@skip_until(Progress.LOOKING_UP_CANDIDATES)
+@set_progress(
+ Progress.LOOKING_UP_CANDIDATES,
+ on_error={NoCandidatesFoundException: Progress.PREVIEW_COMPLETED},
+)
+def lookup_candidates(
+ session: BaseSession,
+ task: ImportTask,
+):
+ """Performing the initial MusicBrainz lookup for an album.
+
+ We tweaks this from upstream beets to not
+ call `task.lookup_candidates()` but instead `session.lookup_candidates(task)`,
+ with some extra logic
+
+ This is more consistent, as it allows the logic
+ to be modified by each kind of session.
+
+ Calls `task.lookup_candidates()`,
+ which sets attributes of the task:
+ - cur_artist # metadata in file
+ - cur_album
+ - candidates
+ - rec
+ """
+ if task.skip:
+ # FIXME This gets duplicated a lot. We need a better
+ # abstraction.
+ return
+
+ # FIXME: what happens with our new skip logic and plugins?
+ plugins.send("import_task_start", session=session, task=task)
+
+ session.lookup_candidates(task)
+
+
+@mutator_stage
+@skip_until(Progress.IDENTIFYING_DUPLICATES)
+@set_progress(before=Progress.IDENTIFYING_DUPLICATES, after=Progress.PREVIEW_COMPLETED)
+def identify_duplicates(
+ session: BaseSession,
+ task: ImportTask,
+):
+ """Stage to identify which candidates would be duplicates if imported."""
+ if task.skip:
+ return
+
+ session.identify_duplicates(task)
+
+
+@stage
+@skip_until(Progress.WAITING_FOR_USER_SELECTION)
+@set_progress(Progress.WAITING_FOR_USER_SELECTION)
+def user_query(
+ session: ImportSession,
+ task: ImportTask,
+) -> ImportTask | beets_pipeline.MultiMessage | Literal["__PIPELINE_BUBBLE__"]:
+ """A coroutine for interfacing with the user about the tagging process.
+
+ The coroutine accepts an ImportTask objects. It uses the
+ session's `choose_match` method to determine the `action` for
+ this task. Depending on the action additional stages are executed
+ and the processed task is yielded.
+
+ It emits the ``import_task_choice`` event for plugins. Plugins have
+ access to the choice via the ``task.choice_flag`` property and may
+ choose to change it.
+ """
+
+ if task.skip:
+ return task
+
+ if session.already_merged(task.paths):
+ # For some reason the type hinter does not like this without cast
+ return cast(Literal["__PIPELINE_BUBBLE__"], beets_pipeline.BUBBLE)
+
+ # Ask the user for a choice.
+ # This calls session.choose_match(task)... but whyyy?
+ task.choose_match(session)
+ plugins.send("import_task_choice", session=session, task=task)
+
+ # TODO: Import as singletons i.e. task.choice_flag is action.TRACKS
+
+ # As albums: group items by albums and create task for each album
+ if task.choice_flag is Action.ALBUMS:
+ log.warning("This should never run via beets-flask!")
+ return _extend_pipeline(
+ [task],
+ group_albums(session),
+ lookup_candidates(session),
+ user_query(session),
+ )
+
+ # Note: this checks the global config for the default action.
+ found_duplicates = task.find_duplicates(session.lib)
+ # Different than vanilla beets, we do not use a stage for duplicate resolution,
+ # as it only calls the sessions method, anyway.
+ session.resolve_duplicate(task, found_duplicates)
+
+ if task.should_merge_duplicates:
+ # Create a new task for tagging the current items
+ # and duplicates together
+ duplicate_items = task.duplicate_items(session.lib)
+
+ # Duplicates would be reimported so make them look "fresh"
+ _freshen_items(duplicate_items)
+ duplicate_paths = [item.path for item in duplicate_items]
+
+ # Record merged paths in the session so they are not reimported
+ session.mark_merged(duplicate_paths)
+
+ merged_task = ImportTask(
+ None, task.paths + duplicate_paths, task.items + duplicate_items
+ )
+ return _extend_pipeline(
+ [merged_task], lookup_candidates(session), user_query(session)
+ )
+
+ _apply_choice(session, task)
+
+ return task
+
+
+# Dynamicly set_progress for plugin name
+# e.g. DetailedProgress(Progress.EARLY_IMPORT, plugin_name="my_plugin")
+@mutator_stage
+def plugin_stage(
+ session: BaseSession,
+ func: Callable[[BaseSession, ImportTask], None],
+ progress: ProgressState,
+ task: ImportTask,
+):
+ # TODO: Skip if already progressed
+ session.set_task_progress(task, progress)
+ if task.skip:
+ return
+
+ log.debug(f"Running plugin stage {func.__name__}")
+ func(session, task)
+ log.debug(f"Finished plugin stage {func.__name__}")
+
+ task.reload()
+ log.debug(f"Reload plugin stage done {func.__name__}")
+
+
+@mutator_stage
+@skip_until(Progress.MATCH_THRESHOLD)
+@set_progress(
+ Progress.MATCH_THRESHOLD,
+ on_error={NotImportedException: Progress.PREVIEW_COMPLETED},
+)
+def match_threshold(
+ session: AutoImportSession,
+ task: ImportTask,
+):
+ """Stage to determine if a match is good enough to be auto-imported."""
+ if task.skip:
+ log.debug(f"Skipping task: {session=}, {task=}")
+ return task
+
+ session.match_threshold(task)
+
+
+# --------------------------------- Consumer --------------------------------- #
+
+
+@stage
+@skip_until(Progress.MANIPULATING_FILES)
+@set_progress(before=Progress.MANIPULATING_FILES, after=Progress.IMPORT_COMPLETED)
+def manipulate_files(
+ session: BaseSession,
+ task: ImportTask,
+) -> ImportTask:
+ """A coroutine (pipeline stage) that performs necessary file.
+
+ manipulations *after* items have been added to the library and
+ finalizes each task.
+ """
+ if not task.skip:
+ if task.should_remove_duplicates:
+ task.remove_duplicates(session.lib)
+
+ if session.config["copy"]:
+ operation = MoveOperation.COPY
+ # elif session.config["move"]:
+ # operation = MoveOperation.MOVE
+ # elif session.config["link"]:
+ # operation = MoveOperation.LINK
+ # elif session.config["hardlink"]:
+ # operation = MoveOperation.HARDLINK
+ # elif session.config["reflink"] == "auto":
+ # operation = MoveOperation.REFLINK_AUTO
+ # elif session.config["reflink"]:
+ # operation = MoveOperation.REFLINK
+ # else:
+ # operation = None
+ else:
+ log.warning(
+ "Beets-flask does not yet support other import modes than 'copy'. "
+ + "Please consider updating your config."
+ )
+ operation = MoveOperation.COPY
+
+ task.manipulate_files(session, operation, write=session.config["write"])
+
+ # Progress, cleanup, and event.
+ task.finalize(session)
+
+ # Cleanup of duplicates. After successful import, we do not want the candidates
+ # to have duplicate-info anymore. The reason is that if we undo the import,
+ # there wont be any duplicates in the library. Also, we dont want to show
+ # the duplicate-badge for things that were imported.
+ # TODO: outlook: add new badges for what has been done with duplicates?
+ task_state = session.state.get_task_state_for_task_raise(task)
+ chosen_candidate = task_state.chosen_candidate_state
+ if chosen_candidate:
+ chosen_candidate.duplicate_ids = []
+ # We remove duplicate info from all candidates.
+ # Although this causes the frontend to possibly not show the duplicate
+ # actions, that is fine: Upon re-import, we raise a duplicate action error,
+ # which is shown to the user, and then the duplicate actions will appear.
+
+ if task_state.toppath is not None and len(session.state.task_states) == 1:
+ # If we have only one task and no toppath this indicates that we are
+ # operating on a temp directory we override the toppath
+ # to the session's folder path.
+ task.toppath = bytestring_path(session.state.folder_path)
+
+ return task
+
+
+@stage
+def finalize(
+ session: BaseSession,
+ task: ImportTask,
+) -> ImportTask:
+ """Give our custom sessions a way to do something at the very end."""
+ session.finalize(task)
+ return task
+
+
+def _apply_choice(session: ImportSession, task: ImportTask):
+ # tweaked version of beets apply_choices.
+ # we do not want to rely on the global config object
+ # (in particular, the set_fields, because we always want to set gui_import_id).
+ # see the original:
+ # from beets.importer import apply_choice
+
+ if task.skip:
+ return
+
+ # Change metadata.
+ if task.apply:
+ task.apply_metadata()
+ plugins.send("import_task_apply", session=session, task=task)
+
+ # We need to reinsert items into the db if they were removed earlier
+ item: BeetsItem
+ for item in task.imported_items():
+ item._db = session.lib
+ item.album_id = None
+ item.id = None
+
+ task.add(session.lib)
+ task.set_fields(session.lib)
+
+ # copy of core logic from set_fields()
+ items: list[BeetsItem] = task.imported_items()
+
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ task_state = session.state.get_task_state_for_task_raise(task)
+ with session.lib.transaction():
+ for item in items:
+ item.set_parse("gui_import_id", task_state.id)
+ item.set_parse("gui_import_date", timestamp)
+ item.store()
+ task.album.set_parse("gui_import_id", task_state.id)
+ task.album.set_parse("gui_import_date", timestamp)
+ task.album.store()
+
+
+__all__ = [
+ "read_tasks",
+ "group_albums",
+ "lookup_candidates",
+ "identify_duplicates",
+ "user_query",
+ "plugin_stage",
+ "manipulate_files",
+ "mark_tasks_completed",
+]
diff --git a/backend/beets_flask/importer/states.py b/backend/beets_flask/importer/states.py
index 1c1d48ce..0c9bba34 100644
--- a/backend/beets_flask/importer/states.py
+++ b/backend/beets_flask/importer/states.py
@@ -1,892 +1,892 @@
-"""State classes represent the current state of an import session."""
-
-from __future__ import annotations
-
-from abc import ABC
-from collections.abc import Sequence
-from dataclasses import dataclass
-from datetime import datetime
-from pathlib import Path
-from typing import Literal, NotRequired, TypedDict, cast
-from uuid import uuid4 as uuid
-
-import beets.ui.commands as uicommands
-from beets import importer
-from beets.ui import _open_library
-from beets.util import bytestring_path, get_most_common_tags
-from deprecated import deprecated
-
-from beets_flask.config import get_config
-from beets_flask.disk import Archive, Folder
-from beets_flask.importer.progress import (
- Progress,
- ProgressState,
- SerializedProgressState,
-)
-from beets_flask.importer.types import DuplicateAction
-from beets_flask.server.exceptions import SerializedException
-from beets_flask.utility import capture_stdout_stderr
-
-from .types import (
- AlbumInfo,
- BeetsAlbum,
- BeetsAlbumInfo,
- BeetsAlbumMatch,
- BeetsDistance,
- BeetsImportTask,
- BeetsItem,
- BeetsLibrary,
- BeetsTrackInfo,
- BeetsTrackMatch,
- ItemInfo,
- TrackInfo,
-)
-
-
-class BaseState(ABC):
- """Base class for all states.
-
- Some shared functionality, but mostly common attributes.
- """
-
- id: str
- created_at: datetime
- updated_at: datetime
-
- def __init__(self) -> None:
- self.id = str(uuid())
- self.created_at = datetime.now()
- self.updated_at = datetime.now()
-
- def serialize(self) -> SerializedBaseState:
- """Serialize the state to a dictionary."""
- return {
- "id": self.id,
- "created_at": self.created_at,
- "updated_at": self.updated_at,
- }
-
-
-@dataclass(init=False)
-class SessionState(BaseState):
- """Highest level state of an import session.
-
- Contains task (selection) states for each task.
- """
-
- id: str
- _task_states: list[TaskState]
- folder_path: Path
- folder_hash: str
-
- # session-level buttons. continue from choose_match when not None
- user_response: Literal["abort"] | Literal["apply"] | None = None
-
- # If a session run fails we store the exc here
- # should be set to none whenever the session is started
- exc: SerializedException | None = None
-
- def __init__(self, folder: Folder | Archive | Path) -> None:
- super().__init__()
-
- # Alternate constructor is part of the SessionStateInDb class
- self._task_states = []
- if isinstance(folder, str):
- folder = Path(folder)
- if isinstance(folder, Path):
- if folder.is_dir():
- # If the path is a file, we assume it is an archive
- folder = Folder.from_path(folder)
- else:
- folder = Archive.from_path(folder)
-
- # Why not just a folder object as member?
- # -> We do not always want to compute the children (or save them to db)
- self.folder_path = folder.path
- self.folder_hash = folder.hash
-
- def __repr__(self) -> str:
- return (
- f"SessionState:\n"
- + f" * id={self.id}\n"
- + f" * folder_path={self.folder_path}\n"
- + f" * folder_hash={self.folder_hash}\n"
- + f" * task_states={[ts.id for ts in self.task_states]}\n"
- + f" * progress={self.progress}"
- )
-
- @property
- @deprecated("Use the folder attribute instead!")
- def path(self) -> Path:
- return self.folder_path
-
- @property
- def task_states(self):
- return self._task_states
-
- @property
- def task_state_ids(self):
- return [s.id for s in self.task_states]
-
- @property
- def tasks(self):
- return [s.task for s in self.task_states]
-
- @property
- def progress(self):
- """The session progress is the loweset progress of all tasks."""
- if len(self.task_states) == 0:
- return ProgressState(Progress.NOT_STARTED)
-
- return min([s.progress for s in self.task_states])
-
- def get_task_state_for_task(
- self,
- task: BeetsImportTask,
- ) -> TaskState | None:
- """Get the task state for a given task.
-
- Returns None if not found.
- """
- state: TaskState | None = None
- for s in self.task_states:
- if s.task == task:
- state = s
- break
- return state
-
- def get_task_state_for_task_raise(
- self,
- task: BeetsImportTask,
- ) -> TaskState:
- """Get the task state for a given task.
-
- Raises ValueError if not found.
- """
- state = self.get_task_state_for_task(task)
- if state is None:
- raise ValueError(f"Task {task} not found in session.")
- return state
-
- def get_task_state_by_id(self, id: str) -> TaskState | None:
- state: TaskState | None = None
- for s in self.task_states:
- if s.id == id:
- state = s
- break
- return state
-
- def upsert_task(
- self,
- task: BeetsImportTask,
- ) -> TaskState:
- """Upsert selection state.
-
- If it does not exist yet it is created or updated
- if entry exists.
- """
- state = self.get_task_state_for_task(task)
-
- if state is None:
- state = TaskState(task)
- self._task_states.append(state)
-
- return state
-
- def remove_task(self, task: BeetsImportTask) -> None:
- """Remove a task from the session state.
-
- If the task does not exist, nothing happens.
- """
- state = self.get_task_state_for_task(task)
- if state is not None:
- self._task_states.remove(state)
-
- def remove_task_by_id(self, id: str) -> None:
- """Remove a task from the session state by id.
-
- If the task does not exist, nothing happens.
- """
- state = self.get_task_state_by_id(id)
- if state is not None:
- self._task_states.remove(state)
-
- def serialize(self) -> SerializedSessionState:
- """JSON representation to match the frontend types."""
-
- r = SerializedSessionState(
- **super().serialize(),
- folder_path=str(self.folder_path),
- folder_hash=str(self.folder_hash),
- tasks=[s.serialize() for s in self.task_states],
- status=self.progress.serialize(),
- )
-
- if self.exc is not None:
- r["exc"] = self.exc
-
- return r
-
-
-@dataclass(init=False)
-class TaskState(BaseState):
- """State representation of a beets ImportTask.
-
- In the frontend, a selection of the available candidates in the task may be needed
- from the user. Exposes some (typed) attributes of the task (e.g. toppath, paths, items)
- Has a list of associated CandidateStates, that represent `matches` in beets.
- """
-
- progress: ProgressState
- task: BeetsImportTask
- candidate_states: list[CandidateState]
- chosen_candidate_state_id: str | None = None
-
- # the completed state blocks the choose_match function
- # of interactive sessions via our await_completion method
- completed: bool = False
-
- # User choices and user input in interactive Session
- # None if no choice has been made yet
- # (or the frontend has not marked the default selection)
- duplicate_action: DuplicateAction | None = None
-
- def __init__(
- self,
- task: BeetsImportTask,
- ) -> None:
- super().__init__()
- # we might run into inconsistencies here, if candidates of the task
- # change. but I do not know when or why they would.
- self.task = task
- self.candidate_states = [CandidateState(c, self) for c in self.task.candidates]
- self.progress = ProgressState()
-
- def __repr__(self) -> str:
- return (
- f"TaskState:\n"
- + f" * id={self.id}\n"
- + f" * candidate_states={[ts.id for ts in self.candidate_states]}\n"
- + f" * chosen_candidate_state_id={self.chosen_candidate_state_id}\n"
- + f" * progress={self.progress}\n"
- + f" * completed={self.completed}\n"
- + f" * toppath={self.toppath}\n"
- )
-
- @property
- def candidates(
- self,
- ) -> Sequence[BeetsAlbumMatch | BeetsTrackMatch]:
- """Task candidates, i.e. possible matches to choose from."""
- return self.task.candidates
-
- @property
- def asis_candidate_id(self) -> str:
- """Id of the asis candidate."""
- return "asis-" + str(self.id)
-
- @property
- def asis_candidate(self) -> CandidateState:
- """Get the asis candidate state."""
- return CandidateState.asis_candidate(self)
-
- def add_candidates(
- self,
- candidates: Sequence[BeetsAlbumMatch | BeetsTrackMatch],
- insert_at: int = 0,
- ) -> list[CandidateState]:
- """Add new candidates to the selection state."""
- if len(self.task.candidates) == 0 or len(self.candidate_states) == 0:
- insert_at = 0
-
- # task.candidates is a sequence and thus immutable
- _ = list(self.task.candidates)
- _[insert_at:insert_at] = candidates
- self.task.candidates = _
-
- new_states = [CandidateState(c, self) for c in candidates]
- self.candidate_states[insert_at:insert_at] = new_states
- return new_states
-
- def get_candidate_state_by_id(self, id: str) -> CandidateState | None:
- """Get candidate state by id."""
- for c in self.candidate_states + [self.asis_candidate]:
- if c.id == id:
- return c
- return None
-
- @property
- def chosen_candidate_state(self) -> CandidateState | None:
- if self.chosen_candidate_state_id is None:
- return None
- return self.get_candidate_state_by_id(self.chosen_candidate_state_id)
-
- @property
- def toppath(self) -> Path | None:
- """Highest-level (common) folder holding music files."""
- if self.task.toppath is not None and isinstance(self.task.toppath, bytes):
- return Path(self.task.toppath.decode("utf-8"))
- return None
-
- @property
- def paths(self) -> list[Path]:
- """Lowest-level folders holding music files."""
- return [Path(p.decode("utf-8")) for p in self.task.paths]
-
- @property
- def item_paths_before_import(self) -> list[Path]:
- """Explicit paths to all media files that would be imported."""
- if self.toppath is None:
- return []
- if self.toppath.is_file():
- return [self.toppath]
-
- items: list[bytes] = []
- for _, i in importer.tasks.albums_in_dir(bytestring_path(self.toppath)):
- # the generator returns a nested list of the outer diretories
- # and file paths. thus, extend and then cast
- items.extend(i)
-
- return [Path(i.decode("utf-8")) for i in items]
-
- @property
- def items(self) -> list[BeetsItem]:
- """Items (representing music files on disk) of the associated task."""
- return [item for item in self.task.items]
-
- @property
- def items_minimal(self) -> list[ItemInfo]:
- """Items of the associated task as MinimalItemAndTrackInfo."""
- return [ItemInfo.from_beets(i) for i in self.task.items]
-
- @property
- def best_candidate_state(self) -> CandidateState | None:
- """Returns the best candidate of this task (never asis)."""
- best = None
- for candidate in self.candidate_states:
- if best is None or candidate.distance < best.distance.distance:
- best = candidate
- return best
-
- @property
- def choice_flag(self) -> importer.tasks.Action | None:
- return self.task.choice_flag
-
- @choice_flag.setter
- def choice_flag(self, value: importer.tasks.Action | None):
- self.task.choice_flag = value
-
- @property
- def current_metadata(self) -> Metadata:
- """Current metadata of the task.
-
- This is the metadata of the music files on disk.
- (In a beets context, cur_artist and cur_album)
- """
- likelies, _ = get_most_common_tags(self.items)
- return Metadata(**{k: str(v) for k, v in likelies.items()}) # type: ignore[typeddict-item]
-
- # ---------------------------------------------------------------------------- #
-
- def serialize(self) -> SerializedTaskState:
- """JSON representation to match the frontend types."""
- return SerializedTaskState(
- **super().serialize(),
- items=self.items_minimal,
- candidates=[c.serialize() for c in self.candidate_states],
- asis_candidate=self.asis_candidate.serialize(),
- current_metadata=self.current_metadata,
- # TODO: maybe we can merge current_metadata (which is cur_artist/album in
- # old beets) into the asis_candidate
- chosen_candidate_id=self.chosen_candidate_state_id,
- duplicate_action=self.duplicate_action,
- completed=self.completed,
- toppath=str(self.toppath),
- paths=[str(p) for p in self.paths],
- )
-
- def set_progress(self, progress: ProgressState | Progress | str) -> None:
- """Set the progress of the task.
-
- If string is given it is set as the message of the current progress.
- """
- if isinstance(progress, ProgressState):
- self.progress = progress
- elif isinstance(progress, Progress):
- self.progress = ProgressState(progress)
- elif isinstance(progress, str):
- # just convenience for debugging should not be used in production
- self.progress.message = progress
- else:
- raise ValueError(f"Unknown progress type: {progress}")
-
-
-@dataclass(init=False)
-class CandidateState(BaseState):
- """
- State representation of a single candidate (match) for an import task.
-
- Can represent an album (self.type == "album") or a track (self.type == "track").
- Keeps a reference to the associated SelectionState, so we can access the beets task.
- Exposes some attributes of the task and match
-
- Note: currently only tested for album matches.
- """
-
- id: str
- duplicate_ids: list[str] # Beets ids of duplicates in the library (album)
- match: BeetsAlbumMatch | BeetsTrackMatch
- # Reference upwards
- task_state: TaskState
-
- _mapping: dict[int, int] # index mapping from items to tracks
-
- def __init__(
- self,
- match: BeetsAlbumMatch | BeetsTrackMatch,
- task_state: TaskState,
- mapping: dict[int, int] | None = None,
- ) -> None:
- super().__init__()
- self.match = match
- self.duplicate_ids = [] # checked and set by session
- self.task_state = task_state
-
- # current_mapping is dynamic and looks at the match to generate integer / index mapping
- # this can cause problems, when loading a previously imported candidate from the db
- # as, in this case, the mapping is wrong and _index_mapping will fail.
- # we take care of this by manually overwriting when constructing from the db.
- self._mapping = mapping or self.current_mapping
-
- def __repr__(self) -> str:
- return (
- f"CandidateState:\n"
- + f" * id={self.id}\n"
- + f" * match={self.match.info.album}\n"
- + f" * task_state_id={self.task_state.id}\n"
- + f" * distance={self.distance}\n"
- + f" * penalties={self.penalties}\n"
- + f" * {len(self.items)=}\n"
- + f" * {len(self.tracks)=}\n"
- + f" * mapping={self.mapping}\n"
- )
-
- @property
- def type(self) -> Literal["album", "track"]:
- if isinstance(self.match, BeetsAlbumMatch):
- return "album"
- elif isinstance(self.match, BeetsTrackMatch):
- return "track"
- else:
- raise ValueError("Unknown type")
-
- @property
- def diff_preview(self) -> str:
- """Diff preview of the match to the current meta data."""
- out, err, _ = capture_stdout_stderr(
- uicommands.show_change,
- self.task_state.task.cur_artist,
- self.task_state.task.cur_album,
- self.match,
- )
- res = out.lstrip("\n")
- if len(err) > 0:
- res += f"\n\nError: {err}"
- return res
-
- @classmethod
- def asis_candidate(cls, task_state: TaskState) -> CandidateState:
- """
- Alternate constructor for an asis import option.
-
- We mock the album match to display
- current meta data in the frontend.
- This is pretty much duct-tape.
- """
- items: list[BeetsItem] = task_state.task.items
-
- # FIXME: we do this lookup twice, once here and once in current_metadata
- if len(items) > 0:
- info, _ = get_most_common_tags(items)
- else:
- info = {}
- info["data_source"] = "asis"
- info["data_url"] = f"file://{task_state.toppath}"
-
- def _generate_kwargs(item):
- kwargs = {}
- # before import the keys are in _dirty,
- # after import in _fields
- for key in list(item._dirty) + list(item._fields):
- val = getattr(item, key)
- if val is not None and val != "":
- kwargs[key] = val
- # tracks use index, items use track, and beets diff preview crashes without index
- kwargs["index"] = item.track or 0
- return kwargs
-
- tracks = [BeetsTrackInfo(**_generate_kwargs(i)) for i in items]
-
- match = BeetsAlbumMatch(
- distance=BeetsDistance(),
- info=BeetsAlbumInfo(
- tracks=tracks,
- **info,
- ),
- extra_items=[],
- extra_tracks=[],
- mapping={i: tracks[idx] for idx, i in enumerate(items)},
- )
- candidate = cls(match=match, task_state=task_state)
- candidate.id = task_state.asis_candidate_id
- # As the asis candidate state is not maintained we not to
- # recheck if it is a duplicate
- candidate.identify_duplicates()
-
- return candidate
-
- # --------------------- Helper to lift / unnset from match to -------------------- #
- @property
- def cur_artist(self) -> str:
- """Current artist, usually the meta data of the music files.
-
- Named to be consistent with beets.
- """
- return str(self.task_state.task.cur_artist)
-
- @property
- def cur_album(self) -> str:
- """Current album, usually the meta data of the music files.
-
- Named to be consistent with beets.
- """
- return str(self.task_state.task.cur_album)
-
- @property
- def artist(self) -> str | None:
- """Artist of the match."""
- return self.match.info.artist
-
- @property
- def album(self) -> str | None:
- """Album of the match."""
- return self.match.info.album
-
- @property
- def items(self) -> list[BeetsItem]:
- """In beets, items refers to the music files on disk.
-
- Tracks correspond to an online match, (or the library?)
- """
- return self.task_state.task.items
-
- @property
- def tracks(self) -> list[BeetsTrackInfo]:
- """Tracks of the match (usually tracks in online match)."""
- if isinstance(self.match, BeetsAlbumMatch):
- return self.match.info.tracks
- return [self.match.info]
-
- @property
- def distance(self) -> BeetsDistance:
- """Distance of the match to the current meta data.
-
- Metadata may be from the task i.e. album or track.
- """
- return self.match.distance
-
- @property
- def penalties(self) -> list[str]:
- penalties = list(self.match.distance.keys())
-
- # renaming for consistency!
- # Beets has a somewhat unintuitive naming:
- # "items" are things on disk
- # "tracks" are meta-data i.e. online.
- # but penalties:
- # "unmatched_tracks" have online not on disk
- # "missing_tracks" have on disk, not online
-
- # beets object | beets penalty | beets_flask
- # -------------|------------------|------------
- # extra_items | unmatched_tracks | extra_items
- # extra_tracks | missing_tracks | extra_tracks
-
- penalties = [
- p.replace("unmatched_tracks", "extra_items").replace(
- "missing_tracks", "extra_tracks"
- )
- for p in penalties
- ]
-
- return list(penalties)
-
- @property
- def num_tracks(self) -> int:
- """Number of tracks in the match (usually tracks in online match)."""
- if isinstance(self.match, BeetsAlbumMatch):
- return len(self.match.info.tracks)
- return 1
-
- @property
- def num_items(self) -> int:
- """Number of items in the task (usually files on disk)."""
- return len(self.items)
-
- @property
- def url(self) -> str | None:
- """URL of the match."""
- if isinstance(self.match, BeetsAlbumMatch):
- try:
- return self.match.info.data_url
- except AttributeError: # not set in the match
- return None
-
- return None
-
- @property
- def is_asis(self) -> bool:
- """Returns True if this is an "as is" candidate."""
- return self.id.startswith("asis-")
-
- @property
- def mapping(self) -> dict[int, int]:
- return self._mapping
-
- @property
- def current_mapping(self) -> dict[int, int]:
- """Get the current mapping from items to tracks, calculated from the match."""
- if isinstance(self.match, BeetsAlbumMatch):
- return _index_mapping(
- self.match.mapping,
- self.items,
- self.tracks,
- )
-
- raise ValueError("Current mapping only available for album matches.")
-
- # ------------------------------------ utility ----------------------------------- #
-
- def identify_duplicates(self, lib: BeetsLibrary | None = None) -> list[BeetsAlbum]:
- """Find duplicates.
-
- Copy of beets' `task.find_duplicates` but works on any candidates' match.
-
- # FIXME: Tracks are not checked for duplicates. Tbh noone cares about tracks anyways
- """
- if lib is None:
- lib = _open_library(get_config())
-
- info = self.match.info.copy()
- info["albumartist"] = info["artist"]
-
- if info["artist"] is None:
- # As-is import with no artist. Skip check.
- return []
-
- # Construct a query to find duplicates with this metadata. We
- # use a temporary Album object to generate any computed fields.
- tmp_album = BeetsAlbum(lib, **info)
- keys: list[str] = cast(
- list[str],
- get_config()["import"]["duplicate_keys"]["album"].as_str_seq() or [],
- )
- dup_query = tmp_album.duplicates_query(keys)
-
- # Re-Importing: Don't count albums with the same files as duplicates.
- task_paths = {i.path for i in self.task_state.task.items if i}
-
- duplicates: list[BeetsAlbum] = []
- for album in lib.albums(dup_query):
- # Check whether the album paths are all present in the task
- # i.e. album is being completely re-imported by the task,
- # in which case it is not a duplicate (will be replaced).
- album_paths = {i.path for i in album.items()}
- if not (album_paths <= task_paths):
- duplicates.append(album)
-
- # Write duplicates information!
- self.duplicate_ids = [d.id for d in duplicates]
-
- return duplicates
-
- @property
- def has_duplicates_in_library(self) -> bool:
- """Returns False, either if no duplicates found, or you have not checked yet.
-
- Call `identify_duplicates` first to ensure this works.
- """
- return len(self.duplicate_ids) > 0
-
- def serialize(self) -> SerializedCandidateState:
- """JSON representation to match the frontend types."""
- # we lift the match.info to reduce nesting in the frontend.
- info: TrackInfo | AlbumInfo
- tracks: list[TrackInfo]
- mapping: dict[int, int] = {}
-
- if isinstance(self.match.info, BeetsTrackInfo):
- # This hardly ever happens, we might support this more in the future
- info = TrackInfo.from_beets(self.match.info)
- tracks = [TrackInfo.from_beets(self.match.info)]
- elif isinstance(self.match.info, BeetsAlbumInfo):
- info = AlbumInfo.from_beets(self.match.info)
-
- # Map beets types to our types, allows serialization magic
- tracks = [TrackInfo.from_beets(track) for track in self.match.info.tracks]
-
- # mapping = _index_mapping(
- # self.match.mapping, # type: ignore
- # self.items,
- # self.tracks,
- # )
- mapping = self.mapping
-
- else:
- raise ValueError(f"Unknown type of matchinfo {type(self.match.info)}")
-
- res = SerializedCandidateState(
- **super().serialize(),
- penalties=self.penalties,
- duplicate_ids=self.duplicate_ids,
- type=self.type,
- distance=self.distance.distance,
- info=info,
- tracks=tracks,
- mapping=mapping,
- )
-
- return res
-
-
-def _index_mapping(
- mapping: dict[BeetsItem, BeetsTrackInfo],
- items: list[BeetsItem],
- tracks: list[BeetsTrackInfo],
-) -> dict[int, int]:
- """Helper to create an index mapping from items to tracks.
-
- the mapping of a beets albummatch uses objects, but we don not want
- to send them over redundantly. convert to an index mapping,
- where first index is in self.items, and second is in self.match.info.tracks
-
- This is used to serialize the mapping of a candidate state.
- """
-
- # log.debug(f"items: {[i.title for i in items]}")
-
- idxs = []
- tdxs = []
- for item, track in mapping.items():
- # log.debug(f"track: {track.index} {track.track_alt} {track.title}")
- # log.debug(f"item: {item.track} {item.title}")
-
- # compare items via paths, and tracks via full dicts
- # we used to compare via track_id, but this might be None.
- found_idx = found_tdx = None
- for idx, _ in enumerate(items):
- if item.path == items[idx].path:
- found_idx = idx
- break
- for tdx, _ in enumerate(tracks):
- if track == tracks[tdx]:
- found_tdx = tdx
- break
- idxs.append(found_idx)
- tdxs.append(found_tdx)
-
- if None in idxs or None in tdxs:
- # breakpoint()
- raise ValueError(
- f"Index mapping failed: {idxs=} {tdxs=} {len(items)=} {len(tracks)=}"
- )
-
- # ignore type for mypy, we have checked that its not None!
- res: dict[int, int] = {idx: tdx for idx, tdx in zip(idxs, tdxs)} # type: ignore[misc]
- # log.debug(f"Index mapping: {res}")
-
- return res
-
-
-# ---------------------------- Serialization types --------------------------- #
-# Used for getting typehints in the frontend. I.e. we generate the types from
-# these typed dicts! See the generate_types.py script for more information.
-
-
-class Metadata(TypedDict):
- """Returned from current_metadata().
-
- FIXME: I think this should not be defined here!
- """
-
- artist: str | None
- album: str | None
- albumartist: str | None
- year: str | None
- disctotal: str | None
- mb_albumid: str | None
- label: str | None
- barcode: str | None
- catalognum: str | None
- country: str | None
- media: str | None
- albumdisambig: str | None
-
-
-class SerializedBaseState(TypedDict):
- """Serialized base state.
-
- This is used to serialize the base state to a dictionary.
- """
-
- id: str
- created_at: datetime
- updated_at: datetime
-
-
-class SerializedSessionState(SerializedBaseState):
- folder_path: str
- folder_hash: str
- tasks: list[SerializedTaskState]
- status: SerializedProgressState
-
- exc: NotRequired[SerializedException | None]
-
-
-class SerializedTaskState(SerializedBaseState):
- items: list[ItemInfo]
- current_metadata: Metadata
-
- # Fetched data
- candidates: list[SerializedCandidateState]
- asis_candidate: SerializedCandidateState
-
- duplicate_action: str | None
- chosen_candidate_id: str | None
- completed: bool
- toppath: str | None
- paths: list[str]
-
-
-class SerializedCandidateState(SerializedBaseState):
- duplicate_ids: list[str]
- type: str
-
- penalties: list[str]
- distance: float
-
- info: TrackInfo | ItemInfo | AlbumInfo
-
- # Mapping from items to tracks index based
- mapping: dict[int, int]
- tracks: list[TrackInfo]
-
-
-__all__ = [
- "SessionState",
- "TaskState",
- "CandidateState",
- "SerializedSessionState",
- "SerializedTaskState",
- "SerializedCandidateState",
-]
+"""State classes represent the current state of an import session."""
+
+from __future__ import annotations
+
+from abc import ABC
+from collections.abc import Sequence
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+from typing import Literal, NotRequired, TypedDict, cast
+from uuid import uuid4 as uuid
+
+import beets.ui.commands as uicommands
+from beets import importer
+from beets.ui import _open_library
+from beets.util import bytestring_path, get_most_common_tags
+from deprecated import deprecated
+
+from beets_flask.config import get_config
+from beets_flask.disk import Archive, Folder
+from beets_flask.importer.progress import (
+ Progress,
+ ProgressState,
+ SerializedProgressState,
+)
+from beets_flask.importer.types import DuplicateAction
+from beets_flask.server.exceptions import SerializedException
+from beets_flask.utility import capture_stdout_stderr
+
+from .types import (
+ AlbumInfo,
+ BeetsAlbum,
+ BeetsAlbumInfo,
+ BeetsAlbumMatch,
+ BeetsDistance,
+ BeetsImportTask,
+ BeetsItem,
+ BeetsLibrary,
+ BeetsTrackInfo,
+ BeetsTrackMatch,
+ ItemInfo,
+ TrackInfo,
+)
+
+
+class BaseState(ABC):
+ """Base class for all states.
+
+ Some shared functionality, but mostly common attributes.
+ """
+
+ id: str
+ created_at: datetime
+ updated_at: datetime
+
+ def __init__(self) -> None:
+ self.id = str(uuid())
+ self.created_at = datetime.now()
+ self.updated_at = datetime.now()
+
+ def serialize(self) -> SerializedBaseState:
+ """Serialize the state to a dictionary."""
+ return {
+ "id": self.id,
+ "created_at": self.created_at,
+ "updated_at": self.updated_at,
+ }
+
+
+@dataclass(init=False)
+class SessionState(BaseState):
+ """Highest level state of an import session.
+
+ Contains task (selection) states for each task.
+ """
+
+ id: str
+ _task_states: list[TaskState]
+ folder_path: Path
+ folder_hash: str
+
+ # session-level buttons. continue from choose_match when not None
+ user_response: Literal["abort"] | Literal["apply"] | None = None
+
+ # If a session run fails we store the exc here
+ # should be set to none whenever the session is started
+ exc: SerializedException | None = None
+
+ def __init__(self, folder: Folder | Archive | Path) -> None:
+ super().__init__()
+
+ # Alternate constructor is part of the SessionStateInDb class
+ self._task_states = []
+ if isinstance(folder, str):
+ folder = Path(folder)
+ if isinstance(folder, Path):
+ if folder.is_dir():
+ # If the path is a file, we assume it is an archive
+ folder = Folder.from_path(folder)
+ else:
+ folder = Archive.from_path(folder)
+
+ # Why not just a folder object as member?
+ # -> We do not always want to compute the children (or save them to db)
+ self.folder_path = folder.path
+ self.folder_hash = folder.hash
+
+ def __repr__(self) -> str:
+ return (
+ f"SessionState:\n"
+ + f" * id={self.id}\n"
+ + f" * folder_path={self.folder_path}\n"
+ + f" * folder_hash={self.folder_hash}\n"
+ + f" * task_states={[ts.id for ts in self.task_states]}\n"
+ + f" * progress={self.progress}"
+ )
+
+ @property
+ @deprecated("Use the folder attribute instead!")
+ def path(self) -> Path:
+ return self.folder_path
+
+ @property
+ def task_states(self):
+ return self._task_states
+
+ @property
+ def task_state_ids(self):
+ return [s.id for s in self.task_states]
+
+ @property
+ def tasks(self):
+ return [s.task for s in self.task_states]
+
+ @property
+ def progress(self):
+ """The session progress is the loweset progress of all tasks."""
+ if len(self.task_states) == 0:
+ return ProgressState(Progress.NOT_STARTED)
+
+ return min([s.progress for s in self.task_states])
+
+ def get_task_state_for_task(
+ self,
+ task: BeetsImportTask,
+ ) -> TaskState | None:
+ """Get the task state for a given task.
+
+ Returns None if not found.
+ """
+ state: TaskState | None = None
+ for s in self.task_states:
+ if s.task == task:
+ state = s
+ break
+ return state
+
+ def get_task_state_for_task_raise(
+ self,
+ task: BeetsImportTask,
+ ) -> TaskState:
+ """Get the task state for a given task.
+
+ Raises ValueError if not found.
+ """
+ state = self.get_task_state_for_task(task)
+ if state is None:
+ raise ValueError(f"Task {task} not found in session.")
+ return state
+
+ def get_task_state_by_id(self, id: str) -> TaskState | None:
+ state: TaskState | None = None
+ for s in self.task_states:
+ if s.id == id:
+ state = s
+ break
+ return state
+
+ def upsert_task(
+ self,
+ task: BeetsImportTask,
+ ) -> TaskState:
+ """Upsert selection state.
+
+ If it does not exist yet it is created or updated
+ if entry exists.
+ """
+ state = self.get_task_state_for_task(task)
+
+ if state is None:
+ state = TaskState(task)
+ self._task_states.append(state)
+
+ return state
+
+ def remove_task(self, task: BeetsImportTask) -> None:
+ """Remove a task from the session state.
+
+ If the task does not exist, nothing happens.
+ """
+ state = self.get_task_state_for_task(task)
+ if state is not None:
+ self._task_states.remove(state)
+
+ def remove_task_by_id(self, id: str) -> None:
+ """Remove a task from the session state by id.
+
+ If the task does not exist, nothing happens.
+ """
+ state = self.get_task_state_by_id(id)
+ if state is not None:
+ self._task_states.remove(state)
+
+ def serialize(self) -> SerializedSessionState:
+ """JSON representation to match the frontend types."""
+
+ r = SerializedSessionState(
+ **super().serialize(),
+ folder_path=str(self.folder_path),
+ folder_hash=str(self.folder_hash),
+ tasks=[s.serialize() for s in self.task_states],
+ status=self.progress.serialize(),
+ )
+
+ if self.exc is not None:
+ r["exc"] = self.exc
+
+ return r
+
+
+@dataclass(init=False)
+class TaskState(BaseState):
+ """State representation of a beets ImportTask.
+
+ In the frontend, a selection of the available candidates in the task may be needed
+ from the user. Exposes some (typed) attributes of the task (e.g. toppath, paths, items)
+ Has a list of associated CandidateStates, that represent `matches` in beets.
+ """
+
+ progress: ProgressState
+ task: BeetsImportTask
+ candidate_states: list[CandidateState]
+ chosen_candidate_state_id: str | None = None
+
+ # the completed state blocks the choose_match function
+ # of interactive sessions via our await_completion method
+ completed: bool = False
+
+ # User choices and user input in interactive Session
+ # None if no choice has been made yet
+ # (or the frontend has not marked the default selection)
+ duplicate_action: DuplicateAction | None = None
+
+ def __init__(
+ self,
+ task: BeetsImportTask,
+ ) -> None:
+ super().__init__()
+ # we might run into inconsistencies here, if candidates of the task
+ # change. but I do not know when or why they would.
+ self.task = task
+ self.candidate_states = [CandidateState(c, self) for c in self.task.candidates]
+ self.progress = ProgressState()
+
+ def __repr__(self) -> str:
+ return (
+ f"TaskState:\n"
+ + f" * id={self.id}\n"
+ + f" * candidate_states={[ts.id for ts in self.candidate_states]}\n"
+ + f" * chosen_candidate_state_id={self.chosen_candidate_state_id}\n"
+ + f" * progress={self.progress}\n"
+ + f" * completed={self.completed}\n"
+ + f" * toppath={self.toppath}\n"
+ )
+
+ @property
+ def candidates(
+ self,
+ ) -> Sequence[BeetsAlbumMatch | BeetsTrackMatch]:
+ """Task candidates, i.e. possible matches to choose from."""
+ return self.task.candidates
+
+ @property
+ def asis_candidate_id(self) -> str:
+ """Id of the asis candidate."""
+ return "asis-" + str(self.id)
+
+ @property
+ def asis_candidate(self) -> CandidateState:
+ """Get the asis candidate state."""
+ return CandidateState.asis_candidate(self)
+
+ def add_candidates(
+ self,
+ candidates: Sequence[BeetsAlbumMatch | BeetsTrackMatch],
+ insert_at: int = 0,
+ ) -> list[CandidateState]:
+ """Add new candidates to the selection state."""
+ if len(self.task.candidates) == 0 or len(self.candidate_states) == 0:
+ insert_at = 0
+
+ # task.candidates is a sequence and thus immutable
+ _ = list(self.task.candidates)
+ _[insert_at:insert_at] = candidates
+ self.task.candidates = _
+
+ new_states = [CandidateState(c, self) for c in candidates]
+ self.candidate_states[insert_at:insert_at] = new_states
+ return new_states
+
+ def get_candidate_state_by_id(self, id: str) -> CandidateState | None:
+ """Get candidate state by id."""
+ for c in self.candidate_states + [self.asis_candidate]:
+ if c.id == id:
+ return c
+ return None
+
+ @property
+ def chosen_candidate_state(self) -> CandidateState | None:
+ if self.chosen_candidate_state_id is None:
+ return None
+ return self.get_candidate_state_by_id(self.chosen_candidate_state_id)
+
+ @property
+ def toppath(self) -> Path | None:
+ """Highest-level (common) folder holding music files."""
+ if self.task.toppath is not None and isinstance(self.task.toppath, bytes):
+ return Path(self.task.toppath.decode("utf-8"))
+ return None
+
+ @property
+ def paths(self) -> list[Path]:
+ """Lowest-level folders holding music files."""
+ return [Path(p.decode("utf-8")) for p in self.task.paths]
+
+ @property
+ def item_paths_before_import(self) -> list[Path]:
+ """Explicit paths to all media files that would be imported."""
+ if self.toppath is None:
+ return []
+ if self.toppath.is_file():
+ return [self.toppath]
+
+ items: list[bytes] = []
+ for _, i in importer.tasks.albums_in_dir(bytestring_path(self.toppath)):
+ # the generator returns a nested list of the outer diretories
+ # and file paths. thus, extend and then cast
+ items.extend(i)
+
+ return [Path(i.decode("utf-8")) for i in items]
+
+ @property
+ def items(self) -> list[BeetsItem]:
+ """Items (representing music files on disk) of the associated task."""
+ return [item for item in self.task.items]
+
+ @property
+ def items_minimal(self) -> list[ItemInfo]:
+ """Items of the associated task as MinimalItemAndTrackInfo."""
+ return [ItemInfo.from_beets(i) for i in self.task.items]
+
+ @property
+ def best_candidate_state(self) -> CandidateState | None:
+ """Returns the best candidate of this task (never asis)."""
+ best = None
+ for candidate in self.candidate_states:
+ if best is None or candidate.distance < best.distance.distance:
+ best = candidate
+ return best
+
+ @property
+ def choice_flag(self) -> importer.tasks.Action | None:
+ return self.task.choice_flag
+
+ @choice_flag.setter
+ def choice_flag(self, value: importer.tasks.Action | None):
+ self.task.choice_flag = value
+
+ @property
+ def current_metadata(self) -> Metadata:
+ """Current metadata of the task.
+
+ This is the metadata of the music files on disk.
+ (In a beets context, cur_artist and cur_album)
+ """
+ likelies, _ = get_most_common_tags(self.items)
+ return Metadata(**{k: str(v) for k, v in likelies.items()}) # type: ignore[typeddict-item]
+
+ # ---------------------------------------------------------------------------- #
+
+ def serialize(self) -> SerializedTaskState:
+ """JSON representation to match the frontend types."""
+ return SerializedTaskState(
+ **super().serialize(),
+ items=self.items_minimal,
+ candidates=[c.serialize() for c in self.candidate_states],
+ asis_candidate=self.asis_candidate.serialize(),
+ current_metadata=self.current_metadata,
+ # TODO: maybe we can merge current_metadata (which is cur_artist/album in
+ # old beets) into the asis_candidate
+ chosen_candidate_id=self.chosen_candidate_state_id,
+ duplicate_action=self.duplicate_action,
+ completed=self.completed,
+ toppath=str(self.toppath),
+ paths=[str(p) for p in self.paths],
+ )
+
+ def set_progress(self, progress: ProgressState | Progress | str) -> None:
+ """Set the progress of the task.
+
+ If string is given it is set as the message of the current progress.
+ """
+ if isinstance(progress, ProgressState):
+ self.progress = progress
+ elif isinstance(progress, Progress):
+ self.progress = ProgressState(progress)
+ elif isinstance(progress, str):
+ # just convenience for debugging should not be used in production
+ self.progress.message = progress
+ else:
+ raise ValueError(f"Unknown progress type: {progress}")
+
+
+@dataclass(init=False)
+class CandidateState(BaseState):
+ """
+ State representation of a single candidate (match) for an import task.
+
+ Can represent an album (self.type == "album") or a track (self.type == "track").
+ Keeps a reference to the associated SelectionState, so we can access the beets task.
+ Exposes some attributes of the task and match
+
+ Note: currently only tested for album matches.
+ """
+
+ id: str
+ duplicate_ids: list[str] # Beets ids of duplicates in the library (album)
+ match: BeetsAlbumMatch | BeetsTrackMatch
+ # Reference upwards
+ task_state: TaskState
+
+ _mapping: dict[int, int] # index mapping from items to tracks
+
+ def __init__(
+ self,
+ match: BeetsAlbumMatch | BeetsTrackMatch,
+ task_state: TaskState,
+ mapping: dict[int, int] | None = None,
+ ) -> None:
+ super().__init__()
+ self.match = match
+ self.duplicate_ids = [] # checked and set by session
+ self.task_state = task_state
+
+ # current_mapping is dynamic and looks at the match to generate integer / index mapping
+ # this can cause problems, when loading a previously imported candidate from the db
+ # as, in this case, the mapping is wrong and _index_mapping will fail.
+ # we take care of this by manually overwriting when constructing from the db.
+ self._mapping = mapping or self.current_mapping
+
+ def __repr__(self) -> str:
+ return (
+ f"CandidateState:\n"
+ + f" * id={self.id}\n"
+ + f" * match={self.match.info.album}\n"
+ + f" * task_state_id={self.task_state.id}\n"
+ + f" * distance={self.distance}\n"
+ + f" * penalties={self.penalties}\n"
+ + f" * {len(self.items)=}\n"
+ + f" * {len(self.tracks)=}\n"
+ + f" * mapping={self.mapping}\n"
+ )
+
+ @property
+ def type(self) -> Literal["album", "track"]:
+ if isinstance(self.match, BeetsAlbumMatch):
+ return "album"
+ elif isinstance(self.match, BeetsTrackMatch):
+ return "track"
+ else:
+ raise ValueError("Unknown type")
+
+ @property
+ def diff_preview(self) -> str:
+ """Diff preview of the match to the current meta data."""
+ out, err, _ = capture_stdout_stderr(
+ uicommands.show_change,
+ self.task_state.task.cur_artist,
+ self.task_state.task.cur_album,
+ self.match,
+ )
+ res = out.lstrip("\n")
+ if len(err) > 0:
+ res += f"\n\nError: {err}"
+ return res
+
+ @classmethod
+ def asis_candidate(cls, task_state: TaskState) -> CandidateState:
+ """
+ Alternate constructor for an asis import option.
+
+ We mock the album match to display
+ current meta data in the frontend.
+ This is pretty much duct-tape.
+ """
+ items: list[BeetsItem] = task_state.task.items
+
+ # FIXME: we do this lookup twice, once here and once in current_metadata
+ if len(items) > 0:
+ info, _ = get_most_common_tags(items)
+ else:
+ info = {}
+ info["data_source"] = "asis"
+ info["data_url"] = f"file://{task_state.toppath}"
+
+ def _generate_kwargs(item):
+ kwargs = {}
+ # before import the keys are in _dirty,
+ # after import in _fields
+ for key in list(item._dirty) + list(item._fields):
+ val = getattr(item, key)
+ if val is not None and val != "":
+ kwargs[key] = val
+ # tracks use index, items use track, and beets diff preview crashes without index
+ kwargs["index"] = item.track or 0
+ return kwargs
+
+ tracks = [BeetsTrackInfo(**_generate_kwargs(i)) for i in items]
+
+ match = BeetsAlbumMatch(
+ distance=BeetsDistance(),
+ info=BeetsAlbumInfo(
+ tracks=tracks,
+ **info,
+ ),
+ extra_items=[],
+ extra_tracks=[],
+ mapping={i: tracks[idx] for idx, i in enumerate(items)},
+ )
+ candidate = cls(match=match, task_state=task_state)
+ candidate.id = task_state.asis_candidate_id
+ # As the asis candidate state is not maintained we not to
+ # recheck if it is a duplicate
+ candidate.identify_duplicates()
+
+ return candidate
+
+ # --------------------- Helper to lift / unnset from match to -------------------- #
+ @property
+ def cur_artist(self) -> str:
+ """Current artist, usually the meta data of the music files.
+
+ Named to be consistent with beets.
+ """
+ return str(self.task_state.task.cur_artist)
+
+ @property
+ def cur_album(self) -> str:
+ """Current album, usually the meta data of the music files.
+
+ Named to be consistent with beets.
+ """
+ return str(self.task_state.task.cur_album)
+
+ @property
+ def artist(self) -> str | None:
+ """Artist of the match."""
+ return self.match.info.artist
+
+ @property
+ def album(self) -> str | None:
+ """Album of the match."""
+ return self.match.info.album
+
+ @property
+ def items(self) -> list[BeetsItem]:
+ """In beets, items refers to the music files on disk.
+
+ Tracks correspond to an online match, (or the library?)
+ """
+ return self.task_state.task.items
+
+ @property
+ def tracks(self) -> list[BeetsTrackInfo]:
+ """Tracks of the match (usually tracks in online match)."""
+ if isinstance(self.match, BeetsAlbumMatch):
+ return self.match.info.tracks
+ return [self.match.info]
+
+ @property
+ def distance(self) -> BeetsDistance:
+ """Distance of the match to the current meta data.
+
+ Metadata may be from the task i.e. album or track.
+ """
+ return self.match.distance
+
+ @property
+ def penalties(self) -> list[str]:
+ penalties = list(self.match.distance.keys())
+
+ # renaming for consistency!
+ # Beets has a somewhat unintuitive naming:
+ # "items" are things on disk
+ # "tracks" are meta-data i.e. online.
+ # but penalties:
+ # "unmatched_tracks" have online not on disk
+ # "missing_tracks" have on disk, not online
+
+ # beets object | beets penalty | beets_flask
+ # -------------|------------------|------------
+ # extra_items | unmatched_tracks | extra_items
+ # extra_tracks | missing_tracks | extra_tracks
+
+ penalties = [
+ p.replace("unmatched_tracks", "extra_items").replace(
+ "missing_tracks", "extra_tracks"
+ )
+ for p in penalties
+ ]
+
+ return list(penalties)
+
+ @property
+ def num_tracks(self) -> int:
+ """Number of tracks in the match (usually tracks in online match)."""
+ if isinstance(self.match, BeetsAlbumMatch):
+ return len(self.match.info.tracks)
+ return 1
+
+ @property
+ def num_items(self) -> int:
+ """Number of items in the task (usually files on disk)."""
+ return len(self.items)
+
+ @property
+ def url(self) -> str | None:
+ """URL of the match."""
+ if isinstance(self.match, BeetsAlbumMatch):
+ try:
+ return self.match.info.data_url
+ except AttributeError: # not set in the match
+ return None
+
+ return None
+
+ @property
+ def is_asis(self) -> bool:
+ """Returns True if this is an "as is" candidate."""
+ return self.id.startswith("asis-")
+
+ @property
+ def mapping(self) -> dict[int, int]:
+ return self._mapping
+
+ @property
+ def current_mapping(self) -> dict[int, int]:
+ """Get the current mapping from items to tracks, calculated from the match."""
+ if isinstance(self.match, BeetsAlbumMatch):
+ return _index_mapping(
+ self.match.mapping,
+ self.items,
+ self.tracks,
+ )
+
+ raise ValueError("Current mapping only available for album matches.")
+
+ # ------------------------------------ utility ----------------------------------- #
+
+ def identify_duplicates(self, lib: BeetsLibrary | None = None) -> list[BeetsAlbum]:
+ """Find duplicates.
+
+ Copy of beets' `task.find_duplicates` but works on any candidates' match.
+
+ # FIXME: Tracks are not checked for duplicates. Tbh noone cares about tracks anyways
+ """
+ if lib is None:
+ lib = _open_library(get_config())
+
+ info = self.match.info.copy()
+ info["albumartist"] = info["artist"]
+
+ if info["artist"] is None:
+ # As-is import with no artist. Skip check.
+ return []
+
+ # Construct a query to find duplicates with this metadata. We
+ # use a temporary Album object to generate any computed fields.
+ tmp_album = BeetsAlbum(lib, **info)
+ keys: list[str] = cast(
+ list[str],
+ get_config()["import"]["duplicate_keys"]["album"].as_str_seq() or [],
+ )
+ dup_query = tmp_album.duplicates_query(keys)
+
+ # Re-Importing: Don't count albums with the same files as duplicates.
+ task_paths = {i.path for i in self.task_state.task.items if i}
+
+ duplicates: list[BeetsAlbum] = []
+ for album in lib.albums(dup_query):
+ # Check whether the album paths are all present in the task
+ # i.e. album is being completely re-imported by the task,
+ # in which case it is not a duplicate (will be replaced).
+ album_paths = {i.path for i in album.items()}
+ if not (album_paths <= task_paths):
+ duplicates.append(album)
+
+ # Write duplicates information!
+ self.duplicate_ids = [d.id for d in duplicates]
+
+ return duplicates
+
+ @property
+ def has_duplicates_in_library(self) -> bool:
+ """Returns False, either if no duplicates found, or you have not checked yet.
+
+ Call `identify_duplicates` first to ensure this works.
+ """
+ return len(self.duplicate_ids) > 0
+
+ def serialize(self) -> SerializedCandidateState:
+ """JSON representation to match the frontend types."""
+ # we lift the match.info to reduce nesting in the frontend.
+ info: TrackInfo | AlbumInfo
+ tracks: list[TrackInfo]
+ mapping: dict[int, int] = {}
+
+ if isinstance(self.match.info, BeetsTrackInfo):
+ # This hardly ever happens, we might support this more in the future
+ info = TrackInfo.from_beets(self.match.info)
+ tracks = [TrackInfo.from_beets(self.match.info)]
+ elif isinstance(self.match.info, BeetsAlbumInfo):
+ info = AlbumInfo.from_beets(self.match.info)
+
+ # Map beets types to our types, allows serialization magic
+ tracks = [TrackInfo.from_beets(track) for track in self.match.info.tracks]
+
+ # mapping = _index_mapping(
+ # self.match.mapping, # type: ignore
+ # self.items,
+ # self.tracks,
+ # )
+ mapping = self.mapping
+
+ else:
+ raise ValueError(f"Unknown type of matchinfo {type(self.match.info)}")
+
+ res = SerializedCandidateState(
+ **super().serialize(),
+ penalties=self.penalties,
+ duplicate_ids=self.duplicate_ids,
+ type=self.type,
+ distance=self.distance.distance,
+ info=info,
+ tracks=tracks,
+ mapping=mapping,
+ )
+
+ return res
+
+
+def _index_mapping(
+ mapping: dict[BeetsItem, BeetsTrackInfo],
+ items: list[BeetsItem],
+ tracks: list[BeetsTrackInfo],
+) -> dict[int, int]:
+ """Helper to create an index mapping from items to tracks.
+
+ the mapping of a beets albummatch uses objects, but we don not want
+ to send them over redundantly. convert to an index mapping,
+ where first index is in self.items, and second is in self.match.info.tracks
+
+ This is used to serialize the mapping of a candidate state.
+ """
+
+ # log.debug(f"items: {[i.title for i in items]}")
+
+ idxs = []
+ tdxs = []
+ for item, track in mapping.items():
+ # log.debug(f"track: {track.index} {track.track_alt} {track.title}")
+ # log.debug(f"item: {item.track} {item.title}")
+
+ # compare items via paths, and tracks via full dicts
+ # we used to compare via track_id, but this might be None.
+ found_idx = found_tdx = None
+ for idx, _ in enumerate(items):
+ if item.path == items[idx].path:
+ found_idx = idx
+ break
+ for tdx, _ in enumerate(tracks):
+ if track == tracks[tdx]:
+ found_tdx = tdx
+ break
+ idxs.append(found_idx)
+ tdxs.append(found_tdx)
+
+ if None in idxs or None in tdxs:
+ # breakpoint()
+ raise ValueError(
+ f"Index mapping failed: {idxs=} {tdxs=} {len(items)=} {len(tracks)=}"
+ )
+
+ # ignore type for mypy, we have checked that its not None!
+ res: dict[int, int] = {idx: tdx for idx, tdx in zip(idxs, tdxs)} # type: ignore[misc]
+ # log.debug(f"Index mapping: {res}")
+
+ return res
+
+
+# ---------------------------- Serialization types --------------------------- #
+# Used for getting typehints in the frontend. I.e. we generate the types from
+# these typed dicts! See the generate_types.py script for more information.
+
+
+class Metadata(TypedDict):
+ """Returned from current_metadata().
+
+ FIXME: I think this should not be defined here!
+ """
+
+ artist: str | None
+ album: str | None
+ albumartist: str | None
+ year: str | None
+ disctotal: str | None
+ mb_albumid: str | None
+ label: str | None
+ barcode: str | None
+ catalognum: str | None
+ country: str | None
+ media: str | None
+ albumdisambig: str | None
+
+
+class SerializedBaseState(TypedDict):
+ """Serialized base state.
+
+ This is used to serialize the base state to a dictionary.
+ """
+
+ id: str
+ created_at: datetime
+ updated_at: datetime
+
+
+class SerializedSessionState(SerializedBaseState):
+ folder_path: str
+ folder_hash: str
+ tasks: list[SerializedTaskState]
+ status: SerializedProgressState
+
+ exc: NotRequired[SerializedException | None]
+
+
+class SerializedTaskState(SerializedBaseState):
+ items: list[ItemInfo]
+ current_metadata: Metadata
+
+ # Fetched data
+ candidates: list[SerializedCandidateState]
+ asis_candidate: SerializedCandidateState
+
+ duplicate_action: str | None
+ chosen_candidate_id: str | None
+ completed: bool
+ toppath: str | None
+ paths: list[str]
+
+
+class SerializedCandidateState(SerializedBaseState):
+ duplicate_ids: list[str]
+ type: str
+
+ penalties: list[str]
+ distance: float
+
+ info: TrackInfo | ItemInfo | AlbumInfo
+
+ # Mapping from items to tracks index based
+ mapping: dict[int, int]
+ tracks: list[TrackInfo]
+
+
+__all__ = [
+ "SessionState",
+ "TaskState",
+ "CandidateState",
+ "SerializedSessionState",
+ "SerializedTaskState",
+ "SerializedCandidateState",
+]
diff --git a/backend/beets_flask/importer/types.py b/backend/beets_flask/importer/types.py
index be11f51f..9dece8ce 100644
--- a/backend/beets_flask/importer/types.py
+++ b/backend/beets_flask/importer/types.py
@@ -1,248 +1,248 @@
-"""Typed versions of data classes that beets uses.
-
-Also includes and our own derivatives.
-"""
-
-from __future__ import annotations
-
-from abc import ABC
-from collections.abc import Callable
-from dataclasses import dataclass
-from typing import (
- Any,
- Literal,
- NamedTuple,
- cast,
-)
-
-from beets import autotag
-from beets.autotag.distance import Distance as BeetsDistance
-from beets.autotag.hooks import AlbumInfo as BeetsAlbumInfo
-from beets.autotag.hooks import AlbumMatch as BeetsAlbumMatch
-from beets.autotag.hooks import TrackInfo as BeetsTrackInfo
-from beets.autotag.hooks import TrackMatch as BeetsTrackMatch
-from beets.importer import ImportTask as BeetsImportTask
-from beets.library import Album as BeetsAlbum
-from beets.library import Item as BeetsItem
-from beets.library import Library as BeetsLibrary
-
-__all__ = [
- # Our stuff
- "MusicInfo",
- "TrackInfo",
- "ItemInfo",
- "AlbumInfo",
- "DuplicateAction",
- # Beets stuff
- "BeetsAlbum",
- "BeetsAlbumInfo",
- "BeetsAlbumMatch",
- "BeetsItem",
- "BeetsTrackInfo",
- "BeetsTrackMatch",
- "BeetsLibrary",
- "BeetsDistance",
- "BeetsImportTask",
-]
-
-# to be consistent with beets, here we do not use an enum.
-# (beets uses strings for duplicate actions)
-DuplicateAction = Literal["skip", "keep", "remove", "merge", "ask"]
-
-
-class PromptChoice(NamedTuple):
- short: str
- long: str
- callback: None | Callable
-
- def serialize(self):
- return {
- "short": self.short,
- "long": self.long,
- "callback": self.callback.__name__ if self.callback else "None",
- }
-
-
-@dataclass
-class MusicInfo(ABC):
- """Shared info for tracks, items and albums.
-
- Items (music files on disk), tracks (trackinfo), and album info are somewhat similar.
- They share many fields --- especially once music has been imported.
- In beets there is no shared baseclass from which the three inherit, but such a common
- base class helps in the frontend.
-
- This is a minimal version of this, where fields exclsuive to one type are None for the
- others. (and inconsistent fields could get renamed?)
-
- @PS: Shouldn't this be an abstract class?
- """
-
- type: Literal["item", "track", "album"]
-
- artist: str | None
- album: str | None
- data_url: str | None
- data_source: str | None
- year: int | None
- genre: str | None
- media: str | None
-
- @classmethod
- def _from_instance(
- cls,
- info: autotag.TrackInfo | autotag.Item | autotag.AlbumInfo,
- remap: dict[str, str] = dict(),
- ):
- """Convert from beets TrackInfo, Item or AlbumInfo to our MusicInfo.
-
- You should only call this in from_beets() methods of the derived classes.
- """
- kwargs = class_attributes_to_kwargs(cls, info, remap=remap)
- if isinstance(info, autotag.TrackInfo):
- kwargs["type"] = "track"
- return TrackInfo(**kwargs)
- elif isinstance(info, BeetsItem):
- return ItemInfo(**kwargs)
- elif isinstance(info, autotag.AlbumInfo):
- kwargs["type"] = "album"
- return AlbumInfo(**kwargs)
-
- raise ValueError(f"Unknown type of info: {info}")
-
- def __repr__(self) -> str:
- res = f"{self.__class__.__name__}"
- res += f"{self.type=} "
- res += f"{self.artist=} "
- res += f"{self.album=}"
- return res
-
-
-def class_attributes_to_kwargs(
- cls,
- obj,
- keys=None,
- remap: dict[str, str] = dict(),
-) -> dict[str, Any]:
- """Convert the attributes of an object to a dictionary of keyword arguments.
-
- May be used for any class. If `keys` is provided, only those keys are used.
-
- Remap allows to map the keys of the object to different keys in the dictionary.
- E.g. if the object has an attribute 'track' and you want to use 'index' in the
- dictionary, you can use `remap={"track": "index"}`.
-
- Paramters
- ---------
- cls: class
- The class of the object to convert _to_ e.g. TrackInfo
- obj: object
- The object to convert _from_ i.e. Datatype from beets
-
- """
- if keys is None:
- keys = cls.__dataclass_fields__.keys()
- # {'index', ...}
- kwargs = dict()
- for k in keys:
- kwargs[k] = getattr(obj, k, None)
- for k in remap.keys():
- kwargs[remap[k]] = getattr(obj, k, None)
- return kwargs
-
-
-@dataclass
-class AlbumInfo(MusicInfo):
- """Mre specific version of MusicInfo for albums.
-
- Attributes are an indicator of what might be available, and can be None.
- """
-
- # disambiguation
- mediums: int | None # number of discs
- country: str | None
- label: str | None
- catalognum: str | None
- albumdisambig: str | None
-
- # Note: dont add 'tracks' here, our candidate states lift them already from album matches
- @classmethod
- def from_beets(cls, info: autotag.AlbumInfo):
- """Helper to convert from beets AlbumInfo to our AlbumInfo."""
- return cast(
- AlbumInfo,
- cls._from_instance(info),
- )
-
-
-@dataclass
-class TrackInfo(MusicInfo):
- """More specific version of MusicInfo for tracks.
-
- Attributes are an indicator of what might be available, and can be None.
- """
-
- title: str | None
- length: float | None
- isrc: str | None
-
- # Allows to compute the mapping in the frontend
- index: int | None # 1-based
- medium_index: int | None
- medium: int | None
-
- @classmethod
- def from_beets(cls, info: autotag.TrackInfo):
- """Helper to convert from beets TrackInfo to our TrackInfo."""
- return cast(
- TrackInfo,
- cls._from_instance(info),
- )
-
-
-@dataclass
-class ItemInfo(MusicInfo):
- """More specific version of MusicInfo for items.
-
- Corresponds to a music file or library item with file on disk.
-
- Attributes are an indicator of what might be available, and can be None.
- """
-
- title: str | None
- length: float | None
- isrc: str | None
-
- index: int | None # 1-based
-
- path: str | None
- bitrate: int | None
- format: str | None
-
- @property
- def track(self) -> int | None:
- """Track number of the item.
-
- In beets vanilla types are somewhat inconsistent, which makes frontend
- code hard to understand.
- items.track for files on disk (1-based index for track number)
- track.index for meta data from candidates (1-based index)
- we consistently used `.index`
- """
- return self.index
-
- @classmethod
- def from_beets(cls, info: autotag.Item):
- """Helper to convert from beets Item to our ItemInfo."""
- return cast(
- ItemInfo,
- cls._from_instance(
- info,
- # beets' vanilla types are somewhat inconsistent, which makes frontend
- # code hard to understand.
- # items.track for files on disk (1-based index for track number)
- # track.index for meta data from candidates (1-based index)
- # we consistently used `.index`
- remap={"track": "index"},
- ),
- )
+"""Typed versions of data classes that beets uses.
+
+Also includes and our own derivatives.
+"""
+
+from __future__ import annotations
+
+from abc import ABC
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import (
+ Any,
+ Literal,
+ NamedTuple,
+ cast,
+)
+
+from beets import autotag
+from beets.autotag.distance import Distance as BeetsDistance
+from beets.autotag.hooks import AlbumInfo as BeetsAlbumInfo
+from beets.autotag.hooks import AlbumMatch as BeetsAlbumMatch
+from beets.autotag.hooks import TrackInfo as BeetsTrackInfo
+from beets.autotag.hooks import TrackMatch as BeetsTrackMatch
+from beets.importer import ImportTask as BeetsImportTask
+from beets.library import Album as BeetsAlbum
+from beets.library import Item as BeetsItem
+from beets.library import Library as BeetsLibrary
+
+__all__ = [
+ # Our stuff
+ "MusicInfo",
+ "TrackInfo",
+ "ItemInfo",
+ "AlbumInfo",
+ "DuplicateAction",
+ # Beets stuff
+ "BeetsAlbum",
+ "BeetsAlbumInfo",
+ "BeetsAlbumMatch",
+ "BeetsItem",
+ "BeetsTrackInfo",
+ "BeetsTrackMatch",
+ "BeetsLibrary",
+ "BeetsDistance",
+ "BeetsImportTask",
+]
+
+# to be consistent with beets, here we do not use an enum.
+# (beets uses strings for duplicate actions)
+DuplicateAction = Literal["skip", "keep", "remove", "merge", "ask"]
+
+
+class PromptChoice(NamedTuple):
+ short: str
+ long: str
+ callback: None | Callable
+
+ def serialize(self):
+ return {
+ "short": self.short,
+ "long": self.long,
+ "callback": self.callback.__name__ if self.callback else "None",
+ }
+
+
+@dataclass
+class MusicInfo(ABC):
+ """Shared info for tracks, items and albums.
+
+ Items (music files on disk), tracks (trackinfo), and album info are somewhat similar.
+ They share many fields --- especially once music has been imported.
+ In beets there is no shared baseclass from which the three inherit, but such a common
+ base class helps in the frontend.
+
+ This is a minimal version of this, where fields exclsuive to one type are None for the
+ others. (and inconsistent fields could get renamed?)
+
+ @PS: Shouldn't this be an abstract class?
+ """
+
+ type: Literal["item", "track", "album"]
+
+ artist: str | None
+ album: str | None
+ data_url: str | None
+ data_source: str | None
+ year: int | None
+ genre: str | None
+ media: str | None
+
+ @classmethod
+ def _from_instance(
+ cls,
+ info: autotag.TrackInfo | autotag.Item | autotag.AlbumInfo,
+ remap: dict[str, str] = dict(),
+ ):
+ """Convert from beets TrackInfo, Item or AlbumInfo to our MusicInfo.
+
+ You should only call this in from_beets() methods of the derived classes.
+ """
+ kwargs = class_attributes_to_kwargs(cls, info, remap=remap)
+ if isinstance(info, autotag.TrackInfo):
+ kwargs["type"] = "track"
+ return TrackInfo(**kwargs)
+ elif isinstance(info, BeetsItem):
+ return ItemInfo(**kwargs)
+ elif isinstance(info, autotag.AlbumInfo):
+ kwargs["type"] = "album"
+ return AlbumInfo(**kwargs)
+
+ raise ValueError(f"Unknown type of info: {info}")
+
+ def __repr__(self) -> str:
+ res = f"{self.__class__.__name__}"
+ res += f"{self.type=} "
+ res += f"{self.artist=} "
+ res += f"{self.album=}"
+ return res
+
+
+def class_attributes_to_kwargs(
+ cls,
+ obj,
+ keys=None,
+ remap: dict[str, str] = dict(),
+) -> dict[str, Any]:
+ """Convert the attributes of an object to a dictionary of keyword arguments.
+
+ May be used for any class. If `keys` is provided, only those keys are used.
+
+ Remap allows to map the keys of the object to different keys in the dictionary.
+ E.g. if the object has an attribute 'track' and you want to use 'index' in the
+ dictionary, you can use `remap={"track": "index"}`.
+
+ Paramters
+ ---------
+ cls: class
+ The class of the object to convert _to_ e.g. TrackInfo
+ obj: object
+ The object to convert _from_ i.e. Datatype from beets
+
+ """
+ if keys is None:
+ keys = cls.__dataclass_fields__.keys()
+ # {'index', ...}
+ kwargs = dict()
+ for k in keys:
+ kwargs[k] = getattr(obj, k, None)
+ for k in remap.keys():
+ kwargs[remap[k]] = getattr(obj, k, None)
+ return kwargs
+
+
+@dataclass
+class AlbumInfo(MusicInfo):
+ """Mre specific version of MusicInfo for albums.
+
+ Attributes are an indicator of what might be available, and can be None.
+ """
+
+ # disambiguation
+ mediums: int | None # number of discs
+ country: str | None
+ label: str | None
+ catalognum: str | None
+ albumdisambig: str | None
+
+ # Note: dont add 'tracks' here, our candidate states lift them already from album matches
+ @classmethod
+ def from_beets(cls, info: autotag.AlbumInfo):
+ """Helper to convert from beets AlbumInfo to our AlbumInfo."""
+ return cast(
+ AlbumInfo,
+ cls._from_instance(info),
+ )
+
+
+@dataclass
+class TrackInfo(MusicInfo):
+ """More specific version of MusicInfo for tracks.
+
+ Attributes are an indicator of what might be available, and can be None.
+ """
+
+ title: str | None
+ length: float | None
+ isrc: str | None
+
+ # Allows to compute the mapping in the frontend
+ index: int | None # 1-based
+ medium_index: int | None
+ medium: int | None
+
+ @classmethod
+ def from_beets(cls, info: autotag.TrackInfo):
+ """Helper to convert from beets TrackInfo to our TrackInfo."""
+ return cast(
+ TrackInfo,
+ cls._from_instance(info),
+ )
+
+
+@dataclass
+class ItemInfo(MusicInfo):
+ """More specific version of MusicInfo for items.
+
+ Corresponds to a music file or library item with file on disk.
+
+ Attributes are an indicator of what might be available, and can be None.
+ """
+
+ title: str | None
+ length: float | None
+ isrc: str | None
+
+ index: int | None # 1-based
+
+ path: str | None
+ bitrate: int | None
+ format: str | None
+
+ @property
+ def track(self) -> int | None:
+ """Track number of the item.
+
+ In beets vanilla types are somewhat inconsistent, which makes frontend
+ code hard to understand.
+ items.track for files on disk (1-based index for track number)
+ track.index for meta data from candidates (1-based index)
+ we consistently used `.index`
+ """
+ return self.index
+
+ @classmethod
+ def from_beets(cls, info: autotag.Item):
+ """Helper to convert from beets Item to our ItemInfo."""
+ return cast(
+ ItemInfo,
+ cls._from_instance(
+ info,
+ # beets' vanilla types are somewhat inconsistent, which makes frontend
+ # code hard to understand.
+ # items.track for files on disk (1-based index for track number)
+ # track.index for meta data from candidates (1-based index)
+ # we consistently used `.index`
+ remap={"track": "index"},
+ ),
+ )
diff --git a/backend/beets_flask/invoker/__init__.py b/backend/beets_flask/invoker/__init__.py
index 8fd246c7..5af3f324 100644
--- a/backend/beets_flask/invoker/__init__.py
+++ b/backend/beets_flask/invoker/__init__.py
@@ -1,7 +1,7 @@
-from .enqueue import EnqueueKind, enqueue, enqueue_delete_items
-
-__all__ = [
- "enqueue",
- "enqueue_delete_items",
- "EnqueueKind",
-]
+from .enqueue import EnqueueKind, enqueue, enqueue_delete_items
+
+__all__ = [
+ "enqueue",
+ "enqueue_delete_items",
+ "EnqueueKind",
+]
diff --git a/backend/beets_flask/invoker/enqueue.py b/backend/beets_flask/invoker/enqueue.py
index 531e13e3..57b49332 100644
--- a/backend/beets_flask/invoker/enqueue.py
+++ b/backend/beets_flask/invoker/enqueue.py
@@ -1,660 +1,674 @@
-from __future__ import annotations
-
-import asyncio
-from collections.abc import Awaitable, Callable
-from enum import Enum
-from typing import (
- TYPE_CHECKING,
- Any,
- Literal,
- ParamSpec,
- TypeVar,
-)
-
-from beets.ui import _open_library
-from sqlalchemy import func, select
-from sqlalchemy.orm import Session
-
-from beets_flask.config import get_config
-from beets_flask.database import db_session_factory
-from beets_flask.database.models.states import (
- FolderInDb,
- SessionState,
- SessionStateInDb,
-)
-from beets_flask.importer.progress import FolderStatus
-from beets_flask.importer.session import (
- AddCandidatesSession,
- AutoImportSession,
- BootlegImportSession,
- CandidateChoice,
- ImportSession,
- PreviewSession,
- Search,
- TaskIdMappingArg,
- UndoSession,
- delete_from_beets,
-)
-from beets_flask.importer.types import DuplicateAction
-from beets_flask.logger import log
-from beets_flask.redis import import_queue, preview_queue
-from beets_flask.server.exceptions import (
- InvalidUsageException,
- exception_as_return_value,
-)
-from beets_flask.server.websocket.status import (
- JobStatusUpdate,
- emit_folder_status,
- send_status_update,
-)
-
-from .job import ExtraJobMeta, _set_job_meta
-
-if TYPE_CHECKING:
- from rq.job import Job
- from rq.queue import Queue
-
-
-def emit_update_on_job_change(job, connection, result, *args, **kwargs):
- """
- Callback for rq enqueue functions to emit a job status update via websocket.
-
- See https://python-rq.org/docs/#success-callback
- """
- log.debug(f"job update for socket {job=} {connection=} {result=} {args=} {kwargs=}")
-
- def _is_serialized_exception(d: Any):
- # I wish we could to instance checks on our SerializedException TypedDict
- if not isinstance(result, dict):
- return False
- if "type" in d and "message" in d.keys():
- # the other keys are optional
- return True
- return False
-
- try:
- asyncio.run(
- send_status_update(
- JobStatusUpdate(
- message="Job status update",
- num_jobs=1,
- job_metas=[job.get_meta()],
- exc=result if _is_serialized_exception(result) else None,
- )
- )
- )
- except Exception as e:
- log.error(f"Failed to emit job update: {e}", exc_info=True)
-
-
-P = ParamSpec("P") # Parameters
-R = TypeVar("R") # Return
-
-
-def _enqueue(
- queue: Queue,
- f: Callable[P, Any | Awaitable[Any]],
- *args: P.args,
- **kwargs: P.kwargs,
-) -> Job:
- """Enqueue a job in redis.
-
- Helper that sets some shared behavior and allows
- to for proper type hinting.
- """
-
- job = queue.enqueue(
- f,
- *args,
- **kwargs,
- on_success=emit_update_on_job_change,
- )
- return job
-
-
-class EnqueueKind(Enum):
- """Enum for the different kinds of sessions we can enqueue."""
-
- PREVIEW = "preview"
- PREVIEW_ADD_CANDIDATES = "preview_add_candidates"
- IMPORT_CANDIDATE = "import_candidate"
- IMPORT_AUTO = "import_auto"
- IMPORT_UNDO = "import_undo"
- IMPORT_BOOTLEG = "import_bootleg"
- # Bootlegs are essentially asis, but does not mean to just import the asis candidate,
- # it has its own session that also groups albums, and skips previews.
-
- _AUTO_IMPORT = "_auto_import"
- _AUTO_PREVIEW = "_auto_preview"
-
- @classmethod
- def from_str(cls, kind: str) -> EnqueueKind:
- """Convert a string to an EnqueueKind enum.
-
- Parameters
- ----------
- kind : str
- The string to convert.
- """
- try:
- return cls[kind.upper()]
- except KeyError:
- raise ValueError(f"Unknown kind {kind}")
-
-
-@emit_folder_status(before=FolderStatus.PENDING)
-async def enqueue(
- hash: str,
- path: str,
- kind: EnqueueKind,
- extra_meta: ExtraJobMeta | None = None,
- **kwargs,
-) -> Job:
- """Delegate a preview or import to a redis worker, depending on its kind.
-
- Parameters
- ----------
- hash : str
- The hash of the folder to enqueue.
- path : str
- The path of the folder to enqueue.
- kind : EnqueueKind
- The kind of the folder to enqueue.
- extra_meta: ExtraJobMeta, optional
- Extra meta data to pass to the job. E.g. use this to assign a reference
- for the frontend to the job, so we can track it via the websocket.
- kwargs : dict
- Additional arguments to pass to the worker functions. Depend on the kind,
- use with care.
- """
- if extra_meta is None:
- extra_meta = ExtraJobMeta()
-
- match kind:
- case EnqueueKind.PREVIEW:
- job = enqueue_preview(hash, path, extra_meta, **kwargs)
- case EnqueueKind.PREVIEW_ADD_CANDIDATES:
- job = enqueue_preview_add_candidates(hash, path, extra_meta, **kwargs)
- case EnqueueKind.IMPORT_AUTO:
- job = enqueue_import_auto(hash, path, extra_meta, **kwargs)
- case EnqueueKind.IMPORT_CANDIDATE:
- job = enqueue_import_candidate(hash, path, extra_meta, **kwargs)
- case EnqueueKind.IMPORT_BOOTLEG:
- job = enqueue_import_bootleg(hash, path, extra_meta, **kwargs)
- case EnqueueKind.IMPORT_UNDO:
- job = enqueue_import_undo(hash, path, extra_meta, **kwargs)
- case _:
- raise InvalidUsageException(f"Unknown kind {kind}")
-
- log.debug(f"Enqueued {job.id=} {job.meta=}")
-
- return job
-
-
-# --------------------------- Enqueue entry points --------------------------- #
-# Mainly input validation and submitting to the redis queue
-
-
-def enqueue_preview(hash: str, path: str, extra_meta: ExtraJobMeta, **kwargs) -> Job:
- group_albums: bool | None = kwargs.pop("group_albums", None)
- autotag: bool | None = kwargs.pop("autotag", None)
-
- if len(kwargs.keys()) > 0:
- raise InvalidUsageException("EnqueueKind.PREVIEW does not accept any kwargs.")
- job = _enqueue(preview_queue, run_preview, hash, path, group_albums, autotag)
- _set_job_meta(job, hash, path, EnqueueKind.PREVIEW, extra_meta)
- return job
-
-
-def enqueue_preview_add_candidates(
- hash: str, path: str, extra_meta: ExtraJobMeta, **kwargs
-) -> Job:
- # May contain search_ids, search_artist, search_album
- # As always to allow task mapping
-
- search: TaskIdMappingArg[Search | Literal["skip"]] = kwargs.pop("search", None)
- if len(kwargs.keys()) > 0:
- raise InvalidUsageException(
- "EnqueueKind.PREVIEW_ADD_CANDIDATES only accepts the following kwargs: "
- + "search"
- )
-
- if search is None:
- raise InvalidUsageException(
- "EnqueueKind.PREVIEW_ADD_CANDIDATES requires a search kwarg."
- )
-
- # kwargs are mixed between our own function and redis enqueue -.-
- # if we accidentally define a redis kwarg for our function, it will be ignored.
- # https://python-rq.org/docs/#enqueueing-jobs
- job = _enqueue(
- preview_queue,
- run_preview_add_candidates,
- hash,
- path,
- search=search,
- )
- _set_job_meta(job, hash, path, EnqueueKind.PREVIEW_ADD_CANDIDATES, extra_meta)
- return job
-
-
-def enqueue_import_candidate(
- hash: str, path: str, extra_meta: ExtraJobMeta, **kwargs
-) -> Job:
- """
- Imports a candidate that has been fetched in a preview session.
-
- Kwargs
- ------
- candidate_id : CandidateChoice | dict[str, CandidateChoice] | None
- A valid candidate id for a candidate that has been fetched in a preview
- session.
- None stands for best, is resolved in the session.
- additionally, if a dict is provided, it maps from task_id to candidate, and dupicate action, respectively.
- duplicate_actions
- See candidate_id.
- TODO: Also allowed: "asis" (no exact match needed, there is only one
- asis-candidate).
- """
-
- candidate_ids: TaskIdMappingArg[CandidateChoice] = kwargs.pop("candidate_ids", None)
- duplicate_actions: TaskIdMappingArg[DuplicateAction] = kwargs.pop(
- "duplicate_actions", None
- )
-
- if len(kwargs.keys()) > 0:
- raise InvalidUsageException(
- "EnqueueKind.IMPORT only accepts the following kwargs: "
- + "candidate_ids, duplicate_actions."
- )
-
- # TODO: Validation: lookup candidates exits
-
- # For convenience: if the user calls this but no preview was generated before,
- # use the auto-import instead (which also fetches previews).
- try:
- # TODO: along with validation:
- # we need a special flag as task_id that stands for "do this for all tasks"
- # used along with candidate_ids length == 1.
- # then, only run the fallback auto-import for the args coming from gui import button
-
- # If the user did not specify a candidate_id, we assume they want the best
- # candidate.
- with db_session_factory() as db_session:
- _get_live_state_by_folder(hash, path, db_session)
- # raises if no state found
- except:
- log.info(
- f"No previous session state fround for {hash=} {path=} "
- + "switching to auto-import"
- )
- return enqueue_import_auto(hash, path, extra_meta)
-
- job = _enqueue(
- import_queue,
- run_import_candidate,
- hash,
- path,
- candidate_ids=candidate_ids,
- duplicate_actions=duplicate_actions,
- )
- _set_job_meta(job, hash, path, EnqueueKind.IMPORT_CANDIDATE, extra_meta)
- return job
-
-
-def enqueue_import_auto(hash: str, path: str, extra_meta: ExtraJobMeta, **kwargs):
- """
- Enqueue an automatic import.
-
- Auto jobs first generate a preview (if needed) and then run an import, which always
- imports the best candidate - but only if the preview is good enough (as specified
- in the users beets config)
-
- This is a two step process, and previews run in another queue (thread) than imports.
-
- See AutoImportSession for more details.
- """
-
- group_albums: bool | None = kwargs.pop("group_albums", None)
- autotag: bool | None = kwargs.pop("autotag", None)
- import_threshold: float | None = kwargs.pop("import_threshold", None)
- duplicate_actions: TaskIdMappingArg[DuplicateAction] = kwargs.pop(
- "duplicate_actions", None
- )
-
- if len(kwargs.keys()) > 0:
- raise InvalidUsageException(
- "EnqueueKind.IMPORT_AUTO only accepts the following kwargs: "
- + "group_albums, autotag, import_threshold, duplicate_actions."
- )
-
- # We only assign the on_success callback (likely coming
- # via a kwarg) to the second job!
- job1 = preview_queue.enqueue(
- run_preview, hash, path, group_albums=group_albums, autotag=autotag, **kwargs
- )
- _set_job_meta(job1, hash, path, EnqueueKind._AUTO_PREVIEW, extra_meta)
- job2 = _enqueue(
- import_queue,
- run_import_auto,
- hash,
- path,
- import_threshold=import_threshold,
- duplicate_actions=duplicate_actions,
- **kwargs,
- # rq has no proper typing therefore our kwargs are not type checked properly
- depends_on=job1, # type: ignore
- )
- _set_job_meta(job2, hash, path, EnqueueKind._AUTO_IMPORT, extra_meta)
-
- return job2
-
-
-def enqueue_import_bootleg(hash: str, path: str, extra_meta: ExtraJobMeta, **kwargs):
- job = _enqueue(import_queue, run_import_bootleg, hash, path, **kwargs)
- _set_job_meta(job, hash, path, EnqueueKind.IMPORT_BOOTLEG, extra_meta)
- return job
-
-
-def enqueue_import_undo(hash: str, path: str, extra_meta: ExtraJobMeta, **kwargs):
- delete_files: bool = kwargs.pop("delete_files", True)
-
- if len(kwargs.keys()) > 0:
- raise InvalidUsageException(
- "EnqueueKind.IMPORT_UNDO only accepts the following kwargs: "
- + "delete_files."
- )
-
- job = _enqueue(
- import_queue,
- run_import_undo,
- hash,
- path,
- delete_files=delete_files,
- )
- _set_job_meta(job, hash, path, EnqueueKind.IMPORT_UNDO, extra_meta)
- return job
-
-
-def enqueue_delete_items(task_ids: list[str]) -> Job:
- """Enqueue to delete items from the beets library.
-
- A bit of a special case as this does not use the normal
- hash and path based enqueueing.
- """
- job = _enqueue(
- import_queue,
- delete_items,
- task_ids,
- True,
- # rq has no proper typing therefore our kwargs are not type checked properly
- at_front=True, # type: ignore
- )
- return job
-
-
-# -------------------- Functions that run in redis workers ------------------- #
-# TODO: We might want to move these to their own file, for a bit better separation of
-# concerns.
-
-
-# redis preview queue
-@exception_as_return_value
-@emit_folder_status(before=FolderStatus.PREVIEWING, after=FolderStatus.PREVIEWED)
-async def run_preview(
- hash: str,
- path: str,
- group_albums: bool | None,
- autotag: bool | None,
-):
- """Fetch candidates for a folder using beets.
-
- Will refetch candidates if this is rerun even if candidates exist
- in the db.
-
- Current convention is we have one session for one folder *has*, but
- We might have multiple sessions for the same folder **path**.
- Previews will **reset** any previous session state in the database, if they
- exist for the same folder hash.
-
- Parameters
- ----------
- hash : str
- The hash of the folder for which to run the preview.
- path : str
- The path of the folder for which to run the preview.
- group_albums : bool | None
- Whether to create multple tasks, one for each album found in the metadata
- of the files. Set to true if you have multiple albums in a single folder.
- If None: get value from beets config.
- autotag : bool | None
- Whether to look up metadata online. If None: get value from beets config.
- """
-
- log.info(f"Preview task on {hash=} {path=}")
-
- with db_session_factory() as db_session:
- f_on_disk = FolderInDb.get_current_on_disk(hash, path)
- if hash != f_on_disk.hash:
- log.warning(
- f"Folder content has changed since the job was scheduled for {path}. "
- + f"Using new content ({f_on_disk.hash}) instead of {hash}"
- )
-
- # here, in preview, we always want to start from a fresh state
- # an existing state would skip the candidate lookup.
- # otherwise, the retag action would not work, as preview starting from
- s_state_live = SessionState(f_on_disk)
- p_session = PreviewSession(
- s_state_live, group_albums=group_albums, autotag=autotag
- )
-
- try:
- await p_session.run_async()
- finally:
- # Get max revision for this folder hash
- stmt = select(func.max(SessionStateInDb.folder_revision)).where(
- SessionStateInDb.folder_hash == hash,
- )
- max_rev = db_session.execute(stmt).scalar_one_or_none()
- new_rev = 0 if max_rev is None else max_rev + 1
- s_state_indb = SessionStateInDb.from_live_state(p_session.state)
- s_state_indb.folder_revision = new_rev
-
- db_session.merge(s_state_indb)
- db_session.commit()
-
- log.info(f"Preview done. {hash=} {path=}")
- return
-
-
-# redis preview queue
-@exception_as_return_value
-@emit_folder_status(before=FolderStatus.PREVIEWING, after=FolderStatus.PREVIEWED)
-async def run_preview_add_candidates(
- hash: str, path: str, search: TaskIdMappingArg[Search | Literal["skip"]]
-):
- """Adds a candidate to an session which is already in the status tagged.
-
- This only works if all session tasks are tagged. I.e. preview completed.
-
- Parameters
- ----------
- search : dict[str, Search]
- A dictionary of task ids to search dicts. No value or none skips the search
- for this task.
- """
- log.info(f"Add preview candidates task on {hash=}")
-
- with db_session_factory() as db_session:
- s_state_live = _get_live_state_by_folder(hash, path, db_session)
-
- a_session = AddCandidatesSession(
- s_state_live,
- search=search,
- )
- try:
- await a_session.run_async()
- finally:
- s_state_indb = SessionStateInDb.from_live_state(a_session.state)
- db_session.merge(instance=s_state_indb)
- db_session.commit()
-
- log.info(f"Add candidates done. {hash=} {path=}")
-
-
-# redis import queue
-@exception_as_return_value
-@emit_folder_status(before=FolderStatus.IMPORTING, after=FolderStatus.IMPORTED)
-async def run_import_candidate(
- hash: str,
- path: str,
- candidate_ids: TaskIdMappingArg[CandidateChoice],
- duplicate_actions: TaskIdMappingArg[DuplicateAction],
-):
- """Imports a candidate that has been fetched in a preview session.
-
- Parameters
- ----------
- candidate_id : optional
- If candidate_id is none the best candidate is used.
- duplicate_action : optional
- If duplicate_action is none, the default action from the config is used.
- """
- log.info(f"Import task on {hash=} {path=}")
-
- with db_session_factory() as db_session:
- s_state_live = _get_live_state_by_folder(hash, path, db_session)
-
- i_session = ImportSession(
- s_state_live,
- candidate_ids=candidate_ids,
- duplicate_actions=duplicate_actions,
- )
-
- try:
- await i_session.run_async()
- finally:
- s_state_indb = SessionStateInDb.from_live_state(i_session.state)
- db_session.merge(instance=s_state_indb)
- db_session.commit()
-
- log.info(f"Import candidate done. {hash=} {path=}")
-
-
-# redis import queue
-@exception_as_return_value
-@emit_folder_status(before=FolderStatus.IMPORTING, after=FolderStatus.IMPORTED)
-async def run_import_auto(
- hash: str,
- path: str,
- import_threshold: float | None,
- duplicate_actions: TaskIdMappingArg[DuplicateAction],
-):
- log.info(f"Auto Import task on {hash=} {path=}")
-
- with db_session_factory() as db_session:
- s_state_live = _get_live_state_by_folder(hash, path, db_session)
- i_session = AutoImportSession(
- s_state_live,
- import_threshold=import_threshold,
- duplicate_actions=duplicate_actions,
- )
-
- try:
- await i_session.run_async()
- finally:
- s_state_indb = SessionStateInDb.from_live_state(i_session.state)
- db_session.merge(instance=s_state_indb)
- db_session.commit()
-
- log.info(f"Auto Import done. {hash=} {path=}")
-
-
-# redis import queue
-@exception_as_return_value
-@emit_folder_status(before=FolderStatus.IMPORTING, after=FolderStatus.IMPORTED)
-async def run_import_bootleg(hash: str, path: str):
- log.info(f"Bootleg Import task on {hash=} {path=}")
-
- with db_session_factory() as db_session:
- # TODO: add duplicate action
- # TODO: sort out how to generate previews for asis candidates
- s_state_live = _get_live_state_by_folder(
- hash, path, create_if_not_exists=True, db_session=db_session
- )
- i_session = BootlegImportSession(s_state_live)
-
- try:
- await i_session.run_async()
- finally:
- s_state_indb = SessionStateInDb.from_live_state(i_session.state)
- db_session.merge(instance=s_state_indb)
- db_session.commit()
-
- log.info(f"Bootleg Import done. {hash=} {path=}")
-
-
-@exception_as_return_value
-@emit_folder_status(before=FolderStatus.DELETING, after=FolderStatus.DELETED)
-async def run_import_undo(hash: str, path: str, delete_files: bool):
- log.info(f"Import Undo task on {hash=} {path=}")
-
- with db_session_factory() as db_session:
- s_state_live = _get_live_state_by_folder(hash, path, db_session)
- i_session = UndoSession(s_state_live, delete_files=delete_files)
-
- try:
- await i_session.run_async()
- finally:
- s_state_indb = SessionStateInDb.from_live_state(i_session.state)
- db_session.merge(instance=s_state_indb)
- db_session.commit()
-
- log.info(f"Import Undo done. {hash=} {path=}")
-
-
-def _get_live_state_by_folder(
- hash: str, path: str, db_session: Session, create_if_not_exists=False
-) -> SessionState:
- f_on_disk = FolderInDb.get_current_on_disk(hash, path)
- if hash != f_on_disk.hash:
- log.warning(
- f"Folder content has changed since the job was scheduled for {path}. "
- + f"Using new content ({f_on_disk.hash}) instead of {hash}"
- )
-
- s_state_indb = SessionStateInDb.get_by_hash_and_path(
- # we warn about hash change, and want the import to still run
- # but on the old hash.
- hash=hash,
- path=path,
- db_session=db_session,
- )
-
- if s_state_indb is None and create_if_not_exists:
- s_state_live = SessionState(f_on_disk)
- return s_state_live
-
- if s_state_indb is None:
- # TODO: rq error handling
- raise InvalidUsageException(
- f"No session state found for {path=} {hash=} "
- + f"fresh_hash_on_disk={f_on_disk}, this should not happen."
- )
-
- log.debug(f"Using existing session state for {path=}")
- s_state_live = s_state_indb.to_live_state()
-
- # we need this expunge, otherwise we cannot overwrite session states:
- # If object id is in session we cant add a new object to the session with the
- # same id this will raise (see below session.merge)
- db_session.expunge_all()
-
- return s_state_live
-
-
-def delete_items(task_ids: list[str], delete_files: bool = True):
- lib = _open_library(get_config())
- for task_id in task_ids:
- delete_from_beets(task_id, delete_files=delete_files, lib=lib)
+from __future__ import annotations
+
+import asyncio
+from collections.abc import Awaitable, Callable
+from enum import Enum
+from pathlib import Path
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ Literal,
+ ParamSpec,
+ TypeVar,
+)
+
+from beets.ui import _open_library
+from sqlalchemy import func, select
+from sqlalchemy.orm import Session
+
+from beets_flask.config import get_config
+from beets_flask.database import db_session_factory
+from beets_flask.database.models.states import (
+ FolderInDb,
+ SessionState,
+ SessionStateInDb,
+)
+from beets_flask.disk import compute_and_store_dir_stats
+from beets_flask.importer.progress import FolderStatus
+from beets_flask.importer.session import (
+ AddCandidatesSession,
+ AutoImportSession,
+ BootlegImportSession,
+ CandidateChoice,
+ ImportSession,
+ PreviewSession,
+ Search,
+ TaskIdMappingArg,
+ UndoSession,
+ delete_from_beets,
+)
+from beets_flask.importer.types import DuplicateAction
+from beets_flask.logger import log
+from beets_flask.redis import import_queue, preview_queue
+from beets_flask.server.exceptions import (
+ InvalidUsageException,
+ exception_as_return_value,
+)
+from beets_flask.server.websocket.status import (
+ JobStatusUpdate,
+ emit_folder_status,
+ send_status_update,
+)
+
+from .job import ExtraJobMeta, _set_job_meta
+
+if TYPE_CHECKING:
+ from rq.job import Job
+ from rq.queue import Queue
+
+
+def emit_update_on_job_change(job, connection, result, *args, **kwargs):
+ """
+ Callback for rq enqueue functions to emit a job status update via websocket.
+
+ See https://python-rq.org/docs/#success-callback
+ """
+ log.debug(f"job update for socket {job=} {connection=} {result=} {args=} {kwargs=}")
+
+ def _is_serialized_exception(d: Any):
+ # I wish we could to instance checks on our SerializedException TypedDict
+ if not isinstance(result, dict):
+ return False
+ if "type" in d and "message" in d.keys():
+ # the other keys are optional
+ return True
+ return False
+
+ try:
+ asyncio.run(
+ send_status_update(
+ JobStatusUpdate(
+ message="Job status update",
+ num_jobs=1,
+ job_metas=[job.get_meta()],
+ exc=result if _is_serialized_exception(result) else None,
+ )
+ )
+ )
+ except Exception as e:
+ log.error(f"Failed to emit job update: {e}", exc_info=True)
+
+
+P = ParamSpec("P") # Parameters
+R = TypeVar("R") # Return
+
+
+def _enqueue(
+ queue: Queue,
+ f: Callable[P, Any | Awaitable[Any]],
+ *args: P.args,
+ **kwargs: P.kwargs,
+) -> Job:
+ """Enqueue a job in redis.
+
+ Helper that sets some shared behavior and allows
+ to for proper type hinting.
+ """
+
+ job = queue.enqueue(
+ f,
+ *args,
+ **kwargs,
+ on_success=emit_update_on_job_change,
+ )
+ return job
+
+
+class EnqueueKind(Enum):
+ """Enum for the different kinds of sessions we can enqueue."""
+
+ PREVIEW = "preview"
+ PREVIEW_ADD_CANDIDATES = "preview_add_candidates"
+ IMPORT_CANDIDATE = "import_candidate"
+ IMPORT_AUTO = "import_auto"
+ IMPORT_UNDO = "import_undo"
+ IMPORT_BOOTLEG = "import_bootleg"
+ # Bootlegs are essentially asis, but does not mean to just import the asis candidate,
+ # it has its own session that also groups albums, and skips previews.
+
+ _AUTO_IMPORT = "_auto_import"
+ _AUTO_PREVIEW = "_auto_preview"
+
+ @classmethod
+ def from_str(cls, kind: str) -> EnqueueKind:
+ """Convert a string to an EnqueueKind enum.
+
+ Parameters
+ ----------
+ kind : str
+ The string to convert.
+ """
+ try:
+ return cls[kind.upper()]
+ except KeyError:
+ raise ValueError(f"Unknown kind {kind}")
+
+
+@emit_folder_status(before=FolderStatus.PENDING)
+async def enqueue(
+ hash: str,
+ path: str,
+ kind: EnqueueKind,
+ extra_meta: ExtraJobMeta | None = None,
+ **kwargs,
+) -> Job:
+ """Delegate a preview or import to a redis worker, depending on its kind.
+
+ Parameters
+ ----------
+ hash : str
+ The hash of the folder to enqueue.
+ path : str
+ The path of the folder to enqueue.
+ kind : EnqueueKind
+ The kind of the folder to enqueue.
+ extra_meta: ExtraJobMeta, optional
+ Extra meta data to pass to the job. E.g. use this to assign a reference
+ for the frontend to the job, so we can track it via the websocket.
+ kwargs : dict
+ Additional arguments to pass to the worker functions. Depend on the kind,
+ use with care.
+ """
+ if extra_meta is None:
+ extra_meta = ExtraJobMeta()
+
+ match kind:
+ case EnqueueKind.PREVIEW:
+ job = enqueue_preview(hash, path, extra_meta, **kwargs)
+ case EnqueueKind.PREVIEW_ADD_CANDIDATES:
+ job = enqueue_preview_add_candidates(hash, path, extra_meta, **kwargs)
+ case EnqueueKind.IMPORT_AUTO:
+ job = enqueue_import_auto(hash, path, extra_meta, **kwargs)
+ case EnqueueKind.IMPORT_CANDIDATE:
+ job = enqueue_import_candidate(hash, path, extra_meta, **kwargs)
+ case EnqueueKind.IMPORT_BOOTLEG:
+ job = enqueue_import_bootleg(hash, path, extra_meta, **kwargs)
+ case EnqueueKind.IMPORT_UNDO:
+ job = enqueue_import_undo(hash, path, extra_meta, **kwargs)
+ case _:
+ raise InvalidUsageException(f"Unknown kind {kind}")
+
+ log.debug(f"Enqueued {job.id=} {job.meta=}")
+
+ return job
+
+
+# --------------------------- Enqueue entry points --------------------------- #
+# Mainly input validation and submitting to the redis queue
+
+
+def enqueue_preview(hash: str, path: str, extra_meta: ExtraJobMeta, **kwargs) -> Job:
+ group_albums: bool | None = kwargs.pop("group_albums", None)
+ autotag: bool | None = kwargs.pop("autotag", None)
+
+ if len(kwargs.keys()) > 0:
+ raise InvalidUsageException("EnqueueKind.PREVIEW does not accept any kwargs.")
+ job = _enqueue(preview_queue, run_preview, hash, path, group_albums, autotag)
+ _set_job_meta(job, hash, path, EnqueueKind.PREVIEW, extra_meta)
+ return job
+
+
+def enqueue_preview_add_candidates(
+ hash: str, path: str, extra_meta: ExtraJobMeta, **kwargs
+) -> Job:
+ # May contain search_ids, search_artist, search_album
+ # As always to allow task mapping
+
+ search: TaskIdMappingArg[Search | Literal["skip"]] = kwargs.pop("search", None)
+ if len(kwargs.keys()) > 0:
+ raise InvalidUsageException(
+ "EnqueueKind.PREVIEW_ADD_CANDIDATES only accepts the following kwargs: "
+ + "search"
+ )
+
+ if search is None:
+ raise InvalidUsageException(
+ "EnqueueKind.PREVIEW_ADD_CANDIDATES requires a search kwarg."
+ )
+
+ # kwargs are mixed between our own function and redis enqueue -.-
+ # if we accidentally define a redis kwarg for our function, it will be ignored.
+ # https://python-rq.org/docs/#enqueueing-jobs
+ job = _enqueue(
+ preview_queue,
+ run_preview_add_candidates,
+ hash,
+ path,
+ search=search,
+ )
+ _set_job_meta(job, hash, path, EnqueueKind.PREVIEW_ADD_CANDIDATES, extra_meta)
+ return job
+
+
+def enqueue_import_candidate(
+ hash: str, path: str, extra_meta: ExtraJobMeta, **kwargs
+) -> Job:
+ """
+ Imports a candidate that has been fetched in a preview session.
+
+ Kwargs
+ ------
+ candidate_id : CandidateChoice | dict[str, CandidateChoice] | None
+ A valid candidate id for a candidate that has been fetched in a preview
+ session.
+ None stands for best, is resolved in the session.
+ additionally, if a dict is provided, it maps from task_id to candidate, and dupicate action, respectively.
+ duplicate_actions
+ See candidate_id.
+ TODO: Also allowed: "asis" (no exact match needed, there is only one
+ asis-candidate).
+ """
+
+ candidate_ids: TaskIdMappingArg[CandidateChoice] = kwargs.pop("candidate_ids", None)
+ duplicate_actions: TaskIdMappingArg[DuplicateAction] = kwargs.pop(
+ "duplicate_actions", None
+ )
+
+ if len(kwargs.keys()) > 0:
+ raise InvalidUsageException(
+ "EnqueueKind.IMPORT only accepts the following kwargs: "
+ + "candidate_ids, duplicate_actions."
+ )
+
+ # TODO: Validation: lookup candidates exits
+
+ # For convenience: if the user calls this but no preview was generated before,
+ # use the auto-import instead (which also fetches previews).
+ try:
+ # TODO: along with validation:
+ # we need a special flag as task_id that stands for "do this for all tasks"
+ # used along with candidate_ids length == 1.
+ # then, only run the fallback auto-import for the args coming from gui import button
+
+ # If the user did not specify a candidate_id, we assume they want the best
+ # candidate.
+ with db_session_factory() as db_session:
+ _get_live_state_by_folder(hash, path, db_session)
+ # raises if no state found
+ except:
+ log.info(
+ f"No previous session state fround for {hash=} {path=} "
+ + "switching to auto-import"
+ )
+ return enqueue_import_auto(hash, path, extra_meta)
+
+ job = _enqueue(
+ import_queue,
+ run_import_candidate,
+ hash,
+ path,
+ candidate_ids=candidate_ids,
+ duplicate_actions=duplicate_actions,
+ )
+ _set_job_meta(job, hash, path, EnqueueKind.IMPORT_CANDIDATE, extra_meta)
+ return job
+
+
+def enqueue_import_auto(hash: str, path: str, extra_meta: ExtraJobMeta, **kwargs):
+ """
+ Enqueue an automatic import.
+
+ Auto jobs first generate a preview (if needed) and then run an import, which always
+ imports the best candidate - but only if the preview is good enough (as specified
+ in the users beets config)
+
+ This is a two step process, and previews run in another queue (thread) than imports.
+
+ See AutoImportSession for more details.
+ """
+
+ group_albums: bool | None = kwargs.pop("group_albums", None)
+ autotag: bool | None = kwargs.pop("autotag", None)
+ import_threshold: float | None = kwargs.pop("import_threshold", None)
+ duplicate_actions: TaskIdMappingArg[DuplicateAction] = kwargs.pop(
+ "duplicate_actions", None
+ )
+
+ if len(kwargs.keys()) > 0:
+ raise InvalidUsageException(
+ "EnqueueKind.IMPORT_AUTO only accepts the following kwargs: "
+ + "group_albums, autotag, import_threshold, duplicate_actions."
+ )
+
+ # We only assign the on_success callback (likely coming
+ # via a kwarg) to the second job!
+ job1 = preview_queue.enqueue(
+ run_preview, hash, path, group_albums=group_albums, autotag=autotag, **kwargs
+ )
+ _set_job_meta(job1, hash, path, EnqueueKind._AUTO_PREVIEW, extra_meta)
+ job2 = _enqueue(
+ import_queue,
+ run_import_auto,
+ hash,
+ path,
+ import_threshold=import_threshold,
+ duplicate_actions=duplicate_actions,
+ **kwargs,
+ # rq has no proper typing therefore our kwargs are not type checked properly
+ depends_on=job1, # type: ignore
+ )
+ _set_job_meta(job2, hash, path, EnqueueKind._AUTO_IMPORT, extra_meta)
+
+ return job2
+
+
+def enqueue_import_bootleg(hash: str, path: str, extra_meta: ExtraJobMeta, **kwargs):
+ job = _enqueue(import_queue, run_import_bootleg, hash, path, **kwargs)
+ _set_job_meta(job, hash, path, EnqueueKind.IMPORT_BOOTLEG, extra_meta)
+ return job
+
+
+def enqueue_import_undo(hash: str, path: str, extra_meta: ExtraJobMeta, **kwargs):
+ delete_files: bool = kwargs.pop("delete_files", True)
+
+ if len(kwargs.keys()) > 0:
+ raise InvalidUsageException(
+ "EnqueueKind.IMPORT_UNDO only accepts the following kwargs: "
+ + "delete_files."
+ )
+
+ job = _enqueue(
+ import_queue,
+ run_import_undo,
+ hash,
+ path,
+ delete_files=delete_files,
+ )
+ _set_job_meta(job, hash, path, EnqueueKind.IMPORT_UNDO, extra_meta)
+ return job
+
+
+def enqueue_delete_items(task_ids: list[str]) -> Job:
+ """Enqueue to delete items from the beets library.
+
+ A bit of a special case as this does not use the normal
+ hash and path based enqueueing.
+ """
+ job = _enqueue(
+ import_queue,
+ delete_items,
+ task_ids,
+ True,
+ # rq has no proper typing therefore our kwargs are not type checked properly
+ at_front=True, # type: ignore
+ )
+ return job
+
+
+# -------------------- Functions that run in redis workers ------------------- #
+# TODO: We might want to move these to their own file, for a bit better separation of
+# concerns.
+
+
+# redis preview queue
+@exception_as_return_value
+@emit_folder_status(before=FolderStatus.PREVIEWING, after=FolderStatus.PREVIEWED)
+async def run_preview(
+ hash: str,
+ path: str,
+ group_albums: bool | None,
+ autotag: bool | None,
+):
+ """Fetch candidates for a folder using beets.
+
+ Will refetch candidates if this is rerun even if candidates exist
+ in the db.
+
+ Current convention is we have one session for one folder *has*, but
+ We might have multiple sessions for the same folder **path**.
+ Previews will **reset** any previous session state in the database, if they
+ exist for the same folder hash.
+
+ Parameters
+ ----------
+ hash : str
+ The hash of the folder for which to run the preview.
+ path : str
+ The path of the folder for which to run the preview.
+ group_albums : bool | None
+ Whether to create multple tasks, one for each album found in the metadata
+ of the files. Set to true if you have multiple albums in a single folder.
+ If None: get value from beets config.
+ autotag : bool | None
+ Whether to look up metadata online. If None: get value from beets config.
+ """
+
+ log.info(f"Preview task on {hash=} {path=}")
+
+ with db_session_factory() as db_session:
+ f_on_disk = FolderInDb.get_current_on_disk(hash, path)
+ if hash != f_on_disk.hash:
+ log.warning(
+ f"Folder content has changed since the job was scheduled for {path}. "
+ + f"Using new content ({f_on_disk.hash}) instead of {hash}"
+ )
+
+ # here, in preview, we always want to start from a fresh state
+ # an existing state would skip the candidate lookup.
+ # otherwise, the retag action would not work, as preview starting from
+ s_state_live = SessionState(f_on_disk)
+ p_session = PreviewSession(
+ s_state_live, group_albums=group_albums, autotag=autotag
+ )
+
+ try:
+ await p_session.run_async()
+ finally:
+ # Get max revision for this folder hash
+ stmt = select(func.max(SessionStateInDb.folder_revision)).where(
+ SessionStateInDb.folder_hash == hash,
+ )
+ max_rev = db_session.execute(stmt).scalar_one_or_none()
+ new_rev = 0 if max_rev is None else max_rev + 1
+ s_state_indb = SessionStateInDb.from_live_state(p_session.state)
+ s_state_indb.folder_revision = new_rev
+
+ db_session.merge(s_state_indb)
+ db_session.commit()
+
+ log.info(f"Preview done. {hash=} {path=}")
+ return
+
+
+# redis preview queue
+@exception_as_return_value
+@emit_folder_status(before=FolderStatus.PREVIEWING, after=FolderStatus.PREVIEWED)
+async def run_preview_add_candidates(
+ hash: str, path: str, search: TaskIdMappingArg[Search | Literal["skip"]]
+):
+ """Adds a candidate to an session which is already in the status tagged.
+
+ This only works if all session tasks are tagged. I.e. preview completed.
+
+ Parameters
+ ----------
+ search : dict[str, Search]
+ A dictionary of task ids to search dicts. No value or none skips the search
+ for this task.
+ """
+ log.info(f"Add preview candidates task on {hash=}")
+
+ with db_session_factory() as db_session:
+ s_state_live = _get_live_state_by_folder(hash, path, db_session)
+
+ a_session = AddCandidatesSession(
+ s_state_live,
+ search=search,
+ )
+ try:
+ await a_session.run_async()
+ finally:
+ s_state_indb = SessionStateInDb.from_live_state(a_session.state)
+ db_session.merge(instance=s_state_indb)
+ db_session.commit()
+
+ log.info(f"Add candidates done. {hash=} {path=}")
+
+
+# redis import queue
+@exception_as_return_value
+@emit_folder_status(before=FolderStatus.IMPORTING, after=FolderStatus.IMPORTED)
+async def run_import_candidate(
+ hash: str,
+ path: str,
+ candidate_ids: TaskIdMappingArg[CandidateChoice],
+ duplicate_actions: TaskIdMappingArg[DuplicateAction],
+):
+ """Imports a candidate that has been fetched in a preview session.
+
+ Parameters
+ ----------
+ candidate_id : optional
+ If candidate_id is none the best candidate is used.
+ duplicate_action : optional
+ If duplicate_action is none, the default action from the config is used.
+ """
+ log.info(f"Import task on {hash=} {path=}")
+
+ with db_session_factory() as db_session:
+ s_state_live = _get_live_state_by_folder(hash, path, db_session)
+
+ i_session = ImportSession(
+ s_state_live,
+ candidate_ids=candidate_ids,
+ duplicate_actions=duplicate_actions,
+ )
+
+ try:
+ await i_session.run_async()
+ finally:
+ s_state_indb = SessionStateInDb.from_live_state(i_session.state)
+ db_session.merge(instance=s_state_indb)
+ db_session.commit()
+
+ # Refresh cached library size so the home page reflects the new import.
+ lib_path = Path(get_config()["directory"].get(str))
+ await compute_and_store_dir_stats(lib_path)
+
+ log.info(f"Import candidate done. {hash=} {path=}")
+
+
+# redis import queue
+@exception_as_return_value
+@emit_folder_status(before=FolderStatus.IMPORTING, after=FolderStatus.IMPORTED)
+async def run_import_auto(
+ hash: str,
+ path: str,
+ import_threshold: float | None,
+ duplicate_actions: TaskIdMappingArg[DuplicateAction],
+):
+ log.info(f"Auto Import task on {hash=} {path=}")
+
+ with db_session_factory() as db_session:
+ s_state_live = _get_live_state_by_folder(hash, path, db_session)
+ i_session = AutoImportSession(
+ s_state_live,
+ import_threshold=import_threshold,
+ duplicate_actions=duplicate_actions,
+ )
+
+ try:
+ await i_session.run_async()
+ finally:
+ s_state_indb = SessionStateInDb.from_live_state(i_session.state)
+ db_session.merge(instance=s_state_indb)
+ db_session.commit()
+
+ # Refresh cached library size so the home page reflects the new import.
+ lib_path = Path(get_config()["directory"].get(str))
+ await compute_and_store_dir_stats(lib_path)
+
+ log.info(f"Auto Import done. {hash=} {path=}")
+
+
+# redis import queue
+@exception_as_return_value
+@emit_folder_status(before=FolderStatus.IMPORTING, after=FolderStatus.IMPORTED)
+async def run_import_bootleg(hash: str, path: str):
+ log.info(f"Bootleg Import task on {hash=} {path=}")
+
+ with db_session_factory() as db_session:
+ # TODO: add duplicate action
+ # TODO: sort out how to generate previews for asis candidates
+ s_state_live = _get_live_state_by_folder(
+ hash, path, create_if_not_exists=True, db_session=db_session
+ )
+ i_session = BootlegImportSession(s_state_live)
+
+ try:
+ await i_session.run_async()
+ finally:
+ s_state_indb = SessionStateInDb.from_live_state(i_session.state)
+ db_session.merge(instance=s_state_indb)
+ db_session.commit()
+
+ # Refresh cached library size so the home page reflects the new import.
+ lib_path = Path(get_config()["directory"].get(str))
+ await compute_and_store_dir_stats(lib_path)
+
+ log.info(f"Bootleg Import done. {hash=} {path=}")
+
+
+@exception_as_return_value
+@emit_folder_status(before=FolderStatus.DELETING, after=FolderStatus.DELETED)
+async def run_import_undo(hash: str, path: str, delete_files: bool):
+ log.info(f"Import Undo task on {hash=} {path=}")
+
+ with db_session_factory() as db_session:
+ s_state_live = _get_live_state_by_folder(hash, path, db_session)
+ i_session = UndoSession(s_state_live, delete_files=delete_files)
+
+ try:
+ await i_session.run_async()
+ finally:
+ s_state_indb = SessionStateInDb.from_live_state(i_session.state)
+ db_session.merge(instance=s_state_indb)
+ db_session.commit()
+
+ log.info(f"Import Undo done. {hash=} {path=}")
+
+
+def _get_live_state_by_folder(
+ hash: str, path: str, db_session: Session, create_if_not_exists=False
+) -> SessionState:
+ f_on_disk = FolderInDb.get_current_on_disk(hash, path)
+ if hash != f_on_disk.hash:
+ log.warning(
+ f"Folder content has changed since the job was scheduled for {path}. "
+ + f"Using new content ({f_on_disk.hash}) instead of {hash}"
+ )
+
+ s_state_indb = SessionStateInDb.get_by_hash_and_path(
+ # we warn about hash change, and want the import to still run
+ # but on the old hash.
+ hash=hash,
+ path=path,
+ db_session=db_session,
+ )
+
+ if s_state_indb is None and create_if_not_exists:
+ s_state_live = SessionState(f_on_disk)
+ return s_state_live
+
+ if s_state_indb is None:
+ # TODO: rq error handling
+ raise InvalidUsageException(
+ f"No session state found for {path=} {hash=} "
+ + f"fresh_hash_on_disk={f_on_disk}, this should not happen."
+ )
+
+ log.debug(f"Using existing session state for {path=}")
+ s_state_live = s_state_indb.to_live_state()
+
+ # we need this expunge, otherwise we cannot overwrite session states:
+ # If object id is in session we cant add a new object to the session with the
+ # same id this will raise (see below session.merge)
+ db_session.expunge_all()
+
+ return s_state_live
+
+
+def delete_items(task_ids: list[str], delete_files: bool = True):
+ lib = _open_library(get_config())
+ for task_id in task_ids:
+ delete_from_beets(task_id, delete_files=delete_files, lib=lib)
diff --git a/backend/beets_flask/invoker/job.py b/backend/beets_flask/invoker/job.py
index 6f66b676..89fc505b 100644
--- a/backend/beets_flask/invoker/job.py
+++ b/backend/beets_flask/invoker/job.py
@@ -1,34 +1,34 @@
-from __future__ import annotations
-
-from typing import TYPE_CHECKING, NotRequired, TypedDict
-
-if TYPE_CHECKING:
- from rq.job import Job
-
- from .enqueue import EnqueueKind
-
-
-class ExtraJobMeta(TypedDict):
- job_frontend_ref: NotRequired[str | None]
-
-
-class RequiredJobMeta(TypedDict):
- folder_hash: str
- folder_path: str
- job_id: str
- job_kind: str # PS: EnqueueKind not json serializable
-
-
-class JobMeta(RequiredJobMeta, ExtraJobMeta):
- pass
-
-
-def _set_job_meta(
- job: Job, hash: str, path: str, kind: EnqueueKind, extra: ExtraJobMeta
-):
- job.meta["folder_hash"] = hash
- job.meta["folder_path"] = path
- job.meta["job_id"] = job.id
- job.meta["job_kind"] = kind.value
- job.meta["job_frontend_ref"] = extra.get("job_frontend_ref", None)
- job.save_meta()
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, NotRequired, TypedDict
+
+if TYPE_CHECKING:
+ from rq.job import Job
+
+ from .enqueue import EnqueueKind
+
+
+class ExtraJobMeta(TypedDict):
+ job_frontend_ref: NotRequired[str | None]
+
+
+class RequiredJobMeta(TypedDict):
+ folder_hash: str
+ folder_path: str
+ job_id: str
+ job_kind: str # PS: EnqueueKind not json serializable
+
+
+class JobMeta(RequiredJobMeta, ExtraJobMeta):
+ pass
+
+
+def _set_job_meta(
+ job: Job, hash: str, path: str, kind: EnqueueKind, extra: ExtraJobMeta
+):
+ job.meta["folder_hash"] = hash
+ job.meta["folder_path"] = path
+ job.meta["job_id"] = job.id
+ job.meta["job_kind"] = kind.value
+ job.meta["job_frontend_ref"] = extra.get("job_frontend_ref", None)
+ job.save_meta()
diff --git a/backend/beets_flask/logger.py b/backend/beets_flask/logger.py
index d47290d6..211ecfe8 100644
--- a/backend/beets_flask/logger.py
+++ b/backend/beets_flask/logger.py
@@ -1,64 +1,64 @@
-import logging
-import logging.config
-import os
-
-LOGGING_CONFIG = {
- "version": 1,
- "disable_existing_loggers": True,
- "formatters": {
- "standard": {
- # https://docs.python.org/3/library/logging.html#logrecord-attributes
- "format": "[%(levelname)s] %(name)s: %(message)s"
- },
- "debug": {
- "format": "[%(levelname)-5s] %(asctime)s %(name)s %(filename)-8s:%(lineno)d %(message)s"
- },
- },
- "handlers": {
- "console": {
- "class": "logging.StreamHandler",
- "level": "INFO",
- "formatter": "standard",
- "stream": "ext://sys.stdout",
- },
- "file": {
- "class": "logging.handlers.RotatingFileHandler",
- "level": "DEBUG",
- "formatter": "debug",
- "filename": os.environ.get("BEETSFLASKLOG", "./beets-flask.log"),
- "maxBytes": 1048576, # 1 MB
- "backupCount": 3,
- },
- },
- "loggers": {
- "": { # root logger
- "handlers": ["console", "file"],
- "level": os.getenv("LOG_LEVEL_OTHERS", logging.WARNING),
- "propagate": False,
- },
- "beets-flask": {
- "handlers": ["console", "file"],
- "level": os.getenv("LOG_LEVEL_BEETSFLASK", logging.INFO),
- "propagate": False,
- },
- },
-}
-
-# On testing only log to console
-if "PYTEST_VERSION" in os.environ:
- # Configure minimal logging for pytest
- logging.basicConfig(
- format="[%(levelname)-5s] %(asctime)s %(name)s %(filename)-8s:%(lineno)d %(message)s",
- )
- logging.getLogger("beets-flask").setLevel(logging.DEBUG)
-else:
- logging.config.dictConfig(LOGGING_CONFIG)
-
-log = logging.getLogger("beets-flask")
-
-rq_name = os.getenv("RQ_JOB_ID", None)
-if rq_name:
- log = log.getChild(rq_name[:4])
-
-
-log.debug("Logging configured!")
+import logging
+import logging.config
+import os
+
+LOGGING_CONFIG = {
+ "version": 1,
+ "disable_existing_loggers": True,
+ "formatters": {
+ "standard": {
+ # https://docs.python.org/3/library/logging.html#logrecord-attributes
+ "format": "[%(levelname)s] %(name)s: %(message)s"
+ },
+ "debug": {
+ "format": "[%(levelname)-5s] %(asctime)s %(name)s %(filename)-8s:%(lineno)d %(message)s"
+ },
+ },
+ "handlers": {
+ "console": {
+ "class": "logging.StreamHandler",
+ "level": "INFO",
+ "formatter": "standard",
+ "stream": "ext://sys.stdout",
+ },
+ "file": {
+ "class": "logging.handlers.RotatingFileHandler",
+ "level": "DEBUG",
+ "formatter": "debug",
+ "filename": os.environ.get("BEETSFLASKLOG", "./beets-flask.log"),
+ "maxBytes": 1048576, # 1 MB
+ "backupCount": 3,
+ },
+ },
+ "loggers": {
+ "": { # root logger
+ "handlers": ["console", "file"],
+ "level": os.getenv("LOG_LEVEL_OTHERS", logging.WARNING),
+ "propagate": False,
+ },
+ "beets-flask": {
+ "handlers": ["console", "file"],
+ "level": os.getenv("LOG_LEVEL_BEETSFLASK", logging.INFO),
+ "propagate": False,
+ },
+ },
+}
+
+# On testing only log to console
+if "PYTEST_VERSION" in os.environ:
+ # Configure minimal logging for pytest
+ logging.basicConfig(
+ format="[%(levelname)-5s] %(asctime)s %(name)s %(filename)-8s:%(lineno)d %(message)s",
+ )
+ logging.getLogger("beets-flask").setLevel(logging.DEBUG)
+else:
+ logging.config.dictConfig(LOGGING_CONFIG)
+
+log = logging.getLogger("beets-flask")
+
+rq_name = os.getenv("RQ_JOB_ID", None)
+if rq_name:
+ log = log.getChild(rq_name[:4])
+
+
+log.debug("Logging configured!")
diff --git a/backend/beets_flask/redis.py b/backend/beets_flask/redis.py
index 1f368ca8..5af1dcad 100644
--- a/backend/beets_flask/redis.py
+++ b/backend/beets_flask/redis.py
@@ -1,70 +1,70 @@
-import asyncio
-import time
-from concurrent.futures import ThreadPoolExecutor
-
-from redis import Redis
-from rq import Queue
-from rq.job import Job
-
-# Setup redis connection
-redis_conn = Redis()
-
-# Init our different queues
-preview_queue = Queue("preview", connection=redis_conn, default_timeout=600)
-import_queue = Queue("import", connection=redis_conn, default_timeout=600)
-
-
-queues = [preview_queue, import_queue]
-
-
-async def wait_for_job_results(
- job: Job, poll_interval: float = 0.5, timeout: float = 300
-):
- """Wait for a job to finish and return the result.
-
- Parameters
- ----------
- job : rq.job.Job
- The job to wait for.
- poll_interval : float, optional
- The interval to poll the job status, by default 0.5
- timeout : float, optional
- The timeout for the job, by default 300
-
- Raises
- ------
- Exception
- If the job fails or times out.
-
- Returns
- -------
- Any
- The result of the job.
- """
-
- start_time = time.time()
-
- with ThreadPoolExecutor() as executor:
- while True:
- # Check if the timeout has been exceeded
- elapsed_time = time.time() - start_time
- if elapsed_time > timeout:
- raise Exception(f"Job timed out after {timeout} seconds")
-
- await asyncio.get_event_loop().run_in_executor(executor, job.refresh)
-
- if job.is_finished:
- return job.return_value(False)
- if job.is_failed:
- raise Exception(f"Job failed: {job.exc_info}")
- # Wait for the job to finish
- await asyncio.sleep(poll_interval)
-
-
-__all__ = [
- "queues",
- "import_queue",
- "preview_queue",
- "redis_conn",
- "wait_for_job_results",
-]
+import asyncio
+import time
+from concurrent.futures import ThreadPoolExecutor
+
+from redis import Redis
+from rq import Queue
+from rq.job import Job
+
+# Setup redis connection
+redis_conn = Redis()
+
+# Init our different queues
+preview_queue = Queue("preview", connection=redis_conn, default_timeout=600)
+import_queue = Queue("import", connection=redis_conn, default_timeout=600)
+
+
+queues = [preview_queue, import_queue]
+
+
+async def wait_for_job_results(
+ job: Job, poll_interval: float = 0.5, timeout: float = 300
+):
+ """Wait for a job to finish and return the result.
+
+ Parameters
+ ----------
+ job : rq.job.Job
+ The job to wait for.
+ poll_interval : float, optional
+ The interval to poll the job status, by default 0.5
+ timeout : float, optional
+ The timeout for the job, by default 300
+
+ Raises
+ ------
+ Exception
+ If the job fails or times out.
+
+ Returns
+ -------
+ Any
+ The result of the job.
+ """
+
+ start_time = time.time()
+
+ with ThreadPoolExecutor() as executor:
+ while True:
+ # Check if the timeout has been exceeded
+ elapsed_time = time.time() - start_time
+ if elapsed_time > timeout:
+ raise Exception(f"Job timed out after {timeout} seconds")
+
+ await asyncio.get_event_loop().run_in_executor(executor, job.refresh)
+
+ if job.is_finished:
+ return job.return_value(False)
+ if job.is_failed:
+ raise Exception(f"Job failed: {job.exc_info}")
+ # Wait for the job to finish
+ await asyncio.sleep(poll_interval)
+
+
+__all__ = [
+ "queues",
+ "import_queue",
+ "preview_queue",
+ "redis_conn",
+ "wait_for_job_results",
+]
diff --git a/backend/beets_flask/server/app.py b/backend/beets_flask/server/app.py
index 49a4d101..23ae108a 100644
--- a/backend/beets_flask/server/app.py
+++ b/backend/beets_flask/server/app.py
@@ -1,86 +1,86 @@
-from __future__ import annotations
-
-import json
-import os
-from dataclasses import asdict, is_dataclass
-from datetime import date, datetime
-from typing import TYPE_CHECKING, Any
-
-from quart import Quart
-
-from ..config.flask_config import ServerConfig, init_server_config
-from ..logger import log
-
-if TYPE_CHECKING:
- from ..config.flask_config import ServerConfig
-
-
-def create_app(config: str | ServerConfig | None = None) -> Quart:
- config = config or os.getenv("BEETSFLASK_ENV", None)
- # create and configure the app
- app = Quart(__name__, instance_relative_config=True)
-
- config = init_server_config(config)
- app.config.from_object(config)
- # make routes with and without trailing slahes the same
- app.url_map.strict_slashes = False
- app.json = CustomProvider(app)
-
- global socketio
- # app.wsgi_app = socketio.WSGIApp(sio, app.wsgi_app)
-
- # sqlite
- from ..database import setup_database
-
- setup_database(app)
-
- # Register different blueprints & websocket routes
- # In production, we use the frontend.py route to deliver vite's dist folder
- from .routes import register_routes
- from .websocket import register_socketio
-
- register_routes(app)
- register_socketio(app)
-
- log.debug("Quart app created!")
-
- return app
-
-
-# ------------------------------- Json encoder ------------------------------- #
-# Allows to serialize bytes and datetime objects in dictionaries to json
-# The default encoder does not support this!
-# Has to be added to the app with app.json = CustomProvider(app)
-# FIXME: We might be able to remove this once our serialized state does not
-# contain bytes or datetime objects
-
-from enum import Enum
-
-from quart.json.provider import DefaultJSONProvider
-
-
-class CustomProvider(DefaultJSONProvider):
- def dumps(self, obj: Any, **kwargs: Any) -> str:
- return json.dumps(obj, cls=Encoder, **kwargs)
-
-
-class Encoder(json.JSONEncoder):
- def default(self, o):
- if isinstance(o, bytes):
- # Mainly used for paths
- # b'/path/to/file' -> '/path/to/file'
- # Might yield strange results for other byte objects
- return o.decode("utf-8")
-
- if isinstance(o, (datetime, date)):
- return o.isoformat()
-
- # Dataclasses are not serializable by default
- if is_dataclass(o) and not isinstance(o, type):
- return asdict(o)
-
- # Enum values are not serializable by default
- if isinstance(o, Enum):
- return o.value
-
- return json.JSONEncoder.default(self, o)
+from __future__ import annotations
+
+import json
+import os
+from dataclasses import asdict, is_dataclass
+from datetime import date, datetime
+from typing import TYPE_CHECKING, Any
+
+from quart import Quart
+
+from ..config.flask_config import ServerConfig, init_server_config
+from ..logger import log
+
+if TYPE_CHECKING:
+ from ..config.flask_config import ServerConfig
+
+
+def create_app(config: str | ServerConfig | None = None) -> Quart:
+ config = config or os.getenv("BEETSFLASK_ENV", None)
+ # create and configure the app
+ app = Quart(__name__, instance_relative_config=True)
+
+ config = init_server_config(config)
+ app.config.from_object(config)
+ # make routes with and without trailing slahes the same
+ app.url_map.strict_slashes = False
+ app.json = CustomProvider(app)
+
+ global socketio
+ # app.wsgi_app = socketio.WSGIApp(sio, app.wsgi_app)
+
+ # sqlite
+ from ..database import setup_database
+
+ setup_database(app)
+
+ # Register different blueprints & websocket routes
+ # In production, we use the frontend.py route to deliver vite's dist folder
+ from .routes import register_routes
+ from .websocket import register_socketio
+
+ register_routes(app)
+ register_socketio(app)
+
+ log.debug("Quart app created!")
+
+ return app
+
+
+# ------------------------------- Json encoder ------------------------------- #
+# Allows to serialize bytes and datetime objects in dictionaries to json
+# The default encoder does not support this!
+# Has to be added to the app with app.json = CustomProvider(app)
+# FIXME: We might be able to remove this once our serialized state does not
+# contain bytes or datetime objects
+
+from enum import Enum
+
+from quart.json.provider import DefaultJSONProvider
+
+
+class CustomProvider(DefaultJSONProvider):
+ def dumps(self, obj: Any, **kwargs: Any) -> str:
+ return json.dumps(obj, cls=Encoder, **kwargs)
+
+
+class Encoder(json.JSONEncoder):
+ def default(self, o):
+ if isinstance(o, bytes):
+ # Mainly used for paths
+ # b'/path/to/file' -> '/path/to/file'
+ # Might yield strange results for other byte objects
+ return o.decode("utf-8")
+
+ if isinstance(o, (datetime, date)):
+ return o.isoformat()
+
+ # Dataclasses are not serializable by default
+ if is_dataclass(o) and not isinstance(o, type):
+ return asdict(o)
+
+ # Enum values are not serializable by default
+ if isinstance(o, Enum):
+ return o.value
+
+ return json.JSONEncoder.default(self, o)
diff --git a/backend/beets_flask/server/exceptions.py b/backend/beets_flask/server/exceptions.py
index 0375d1cc..07d3eb1e 100644
--- a/backend/beets_flask/server/exceptions.py
+++ b/backend/beets_flask/server/exceptions.py
@@ -1,212 +1,212 @@
-import traceback
-from collections.abc import Awaitable, Callable
-from functools import wraps
-from typing import NotRequired, ParamSpec, TypedDict, TypeVar
-
-from beets_flask.logger import log
-
-
-class SerializedException(TypedDict):
- """Serialized exception format.
-
- This is used to serialize exceptions to a common format.
- The format is as follows:
- {
- "type": "Exception type",
- "message": "Error message",
- "description": "Error description (optional)"
- }
- """
-
- type: str
- message: str
- description: NotRequired[str | None]
- trace: NotRequired[str | None]
-
-
-class ApiException(Exception):
- """Base class for all API errors."""
-
- persist_in_db: bool
- """If true, the exception will be stored in the database on
- raise in sessions.
- TODO: Think about exception hierarchy.
- """
- status_code: int = 500
-
- def __init__(
- self, *args, status_code: int | None = None, persist_in_db: bool = True
- ):
- super().__init__(*args)
- if status_code is not None:
- self.status_code = status_code
- self.persist_in_db = persist_in_db
-
-
-class InvalidUsageException(ApiException):
- """Invalid usage of the API.
-
- This is used to indicate that the API was used incorrectly.
- """
-
- status_code: int = 400
-
-
-class NotFoundException(ApiException):
- """Resource not found.
-
- This is used to indicate that the requested resource was not found.
- """
-
- status_code: int = 404
-
-
-class IntegrityException(ApiException):
- """Integrity error.
-
- This is used to indicate that the requested resource was not found.
- """
-
- status_code: int = 409
-
-
-class NotImportedException(ApiException):
- """Not imported error.
-
- So far only used for the auto import session, when the best
- match is worse than the threshold.
- """
-
- status_code: int = 409
-
-
-class NoCandidatesFoundException(ApiException):
- """No candidates found error.
-
- Raised when an online search does not return any candidates.
- Could be raised from automatic search (without searchid) but also when manually
- adding more candidates via interactive search (searchid given).
- """
-
- status_code: int = 409
-
- def __init__(
- self, *args, status_code: int | None = None, persist_in_db: bool = True
- ):
- if not args:
- error_text = "Lookup found no candidates. " + self.metadata_plugin_info()
- args = (error_text,)
-
- super().__init__(*args, status_code=status_code, persist_in_db=persist_in_db)
-
- @classmethod
- def metadata_plugin_info(cls) -> str:
- # Get enabled metadata source plugins to give a better error message
- error_text = ""
- try:
- from beets.metadata_plugins import find_metadata_source_plugins
-
- meta_plugins: list[str] = [
- p.data_source for p in find_metadata_source_plugins()
- ]
- if len(meta_plugins) > 0:
- error_text += f"Used '{', '.join(meta_plugins)}' as metadata source(s)."
- else:
- error_text += "No source plugins are enabled."
-
- except:
- error_text += "Could not determine enabled metadata source plugins."
- return error_text
-
-
-class UserException(Exception):
- """Base class for errors caused by user input or config."""
-
- status_code: int = 422
-
- def __init__(self, *args, status_code: int | None = None):
- super().__init__(*args)
- if status_code is not None:
- self.status_code = status_code
-
-
-class DuplicateException(UserException):
- """Duplicate error.
-
- Raised when we have trouble resolving duplicates in the beets library.
- Users should check their config and api usage.
- """
-
- status_code: int = 422
-
-
-def to_serialized_exception(
- exception: Exception,
-) -> SerializedException:
- """Convert an exception to a serialized format.
-
- Parameters
- ----------
- exception : Exception | None
- The exception to serialize.
-
- Returns
- -------
- SerializedException
- The serialized exception.
- """
-
- if exception is None:
- return None
-
- tb: str | None = None
-
- if exception.__traceback__ is not None:
- tb = "".join(traceback.format_tb(exception.__traceback__))
-
- return SerializedException(
- type=exception.__class__.__name__,
- message=str(exception),
- description=exception.__doc__,
- trace=tb,
- )
-
-
-P = ParamSpec("P") # Parameters
-R = TypeVar("R") # Return
-
-
-def exception_as_return_value(
- f: Callable[P, Awaitable[R]],
-) -> Callable[P, Awaitable[R | SerializedException]]:
- """Decorator to catch exceptions and return them as a values.
-
- This is used to catch exceptions in the redis worker and return them
- as a values we can use in the frontend. Sadly standard exeption handling
- in rq is lacking!
- """
-
- @wraps(f)
- async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | SerializedException:
- try:
- return await f(*args, **kwargs)
- # Some exceptions are not serializable, so we need to convert them to a
- # serialized format. E.g. OSErrors
- except ApiException as e:
- log.info(e)
- return to_serialized_exception(e)
- except Exception as e:
- log.exception(e)
- return to_serialized_exception(e)
-
- return wrapper
-
-
-__all__ = [
- "SerializedException",
- "ApiException",
- "InvalidUsageException",
- "NotFoundException",
- "IntegrityException",
- "to_serialized_exception",
-]
+import traceback
+from collections.abc import Awaitable, Callable
+from functools import wraps
+from typing import NotRequired, ParamSpec, TypedDict, TypeVar
+
+from beets_flask.logger import log
+
+
+class SerializedException(TypedDict):
+ """Serialized exception format.
+
+ This is used to serialize exceptions to a common format.
+ The format is as follows:
+ {
+ "type": "Exception type",
+ "message": "Error message",
+ "description": "Error description (optional)"
+ }
+ """
+
+ type: str
+ message: str
+ description: NotRequired[str | None]
+ trace: NotRequired[str | None]
+
+
+class ApiException(Exception):
+ """Base class for all API errors."""
+
+ persist_in_db: bool
+ """If true, the exception will be stored in the database on
+ raise in sessions.
+ TODO: Think about exception hierarchy.
+ """
+ status_code: int = 500
+
+ def __init__(
+ self, *args, status_code: int | None = None, persist_in_db: bool = True
+ ):
+ super().__init__(*args)
+ if status_code is not None:
+ self.status_code = status_code
+ self.persist_in_db = persist_in_db
+
+
+class InvalidUsageException(ApiException):
+ """Invalid usage of the API.
+
+ This is used to indicate that the API was used incorrectly.
+ """
+
+ status_code: int = 400
+
+
+class NotFoundException(ApiException):
+ """Resource not found.
+
+ This is used to indicate that the requested resource was not found.
+ """
+
+ status_code: int = 404
+
+
+class IntegrityException(ApiException):
+ """Integrity error.
+
+ This is used to indicate that the requested resource was not found.
+ """
+
+ status_code: int = 409
+
+
+class NotImportedException(ApiException):
+ """Not imported error.
+
+ So far only used for the auto import session, when the best
+ match is worse than the threshold.
+ """
+
+ status_code: int = 409
+
+
+class NoCandidatesFoundException(ApiException):
+ """No candidates found error.
+
+ Raised when an online search does not return any candidates.
+ Could be raised from automatic search (without searchid) but also when manually
+ adding more candidates via interactive search (searchid given).
+ """
+
+ status_code: int = 409
+
+ def __init__(
+ self, *args, status_code: int | None = None, persist_in_db: bool = True
+ ):
+ if not args:
+ error_text = "Lookup found no candidates. " + self.metadata_plugin_info()
+ args = (error_text,)
+
+ super().__init__(*args, status_code=status_code, persist_in_db=persist_in_db)
+
+ @classmethod
+ def metadata_plugin_info(cls) -> str:
+ # Get enabled metadata source plugins to give a better error message
+ error_text = ""
+ try:
+ from beets.metadata_plugins import find_metadata_source_plugins
+
+ meta_plugins: list[str] = [
+ p.data_source for p in find_metadata_source_plugins()
+ ]
+ if len(meta_plugins) > 0:
+ error_text += f"Used '{', '.join(meta_plugins)}' as metadata source(s)."
+ else:
+ error_text += "No source plugins are enabled."
+
+ except:
+ error_text += "Could not determine enabled metadata source plugins."
+ return error_text
+
+
+class UserException(Exception):
+ """Base class for errors caused by user input or config."""
+
+ status_code: int = 422
+
+ def __init__(self, *args, status_code: int | None = None):
+ super().__init__(*args)
+ if status_code is not None:
+ self.status_code = status_code
+
+
+class DuplicateException(UserException):
+ """Duplicate error.
+
+ Raised when we have trouble resolving duplicates in the beets library.
+ Users should check their config and api usage.
+ """
+
+ status_code: int = 422
+
+
+def to_serialized_exception(
+ exception: Exception,
+) -> SerializedException:
+ """Convert an exception to a serialized format.
+
+ Parameters
+ ----------
+ exception : Exception | None
+ The exception to serialize.
+
+ Returns
+ -------
+ SerializedException
+ The serialized exception.
+ """
+
+ if exception is None:
+ return None
+
+ tb: str | None = None
+
+ if exception.__traceback__ is not None:
+ tb = "".join(traceback.format_tb(exception.__traceback__))
+
+ return SerializedException(
+ type=exception.__class__.__name__,
+ message=str(exception),
+ description=exception.__doc__,
+ trace=tb,
+ )
+
+
+P = ParamSpec("P") # Parameters
+R = TypeVar("R") # Return
+
+
+def exception_as_return_value(
+ f: Callable[P, Awaitable[R]],
+) -> Callable[P, Awaitable[R | SerializedException]]:
+ """Decorator to catch exceptions and return them as a values.
+
+ This is used to catch exceptions in the redis worker and return them
+ as a values we can use in the frontend. Sadly standard exeption handling
+ in rq is lacking!
+ """
+
+ @wraps(f)
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | SerializedException:
+ try:
+ return await f(*args, **kwargs)
+ # Some exceptions are not serializable, so we need to convert them to a
+ # serialized format. E.g. OSErrors
+ except ApiException as e:
+ log.info(e)
+ return to_serialized_exception(e)
+ except Exception as e:
+ log.exception(e)
+ return to_serialized_exception(e)
+
+ return wrapper
+
+
+__all__ = [
+ "SerializedException",
+ "ApiException",
+ "InvalidUsageException",
+ "NotFoundException",
+ "IntegrityException",
+ "to_serialized_exception",
+]
diff --git a/backend/beets_flask/server/routes/__init__.py b/backend/beets_flask/server/routes/__init__.py
index f0843e2c..6434f7e2 100644
--- a/backend/beets_flask/server/routes/__init__.py
+++ b/backend/beets_flask/server/routes/__init__.py
@@ -1,33 +1,33 @@
-from quart import Blueprint, Quart
-
-from .art_preview import art_blueprint
-from .config import config_bp
-from .db_models import register_state_models
-from .exception import error_bp
-from .frontend import frontend_bp
-from .inbox import inbox_bp
-from .library import library_bp
-from .monitor import monitor_bp
-
-backend_bp = Blueprint("backend", __name__, url_prefix="/api_v1")
-
-# Register all backend blueprints
-backend_bp.register_blueprint(art_blueprint)
-backend_bp.register_blueprint(config_bp)
-backend_bp.register_blueprint(error_bp)
-backend_bp.register_blueprint(frontend_bp)
-backend_bp.register_blueprint(inbox_bp)
-backend_bp.register_blueprint(library_bp)
-backend_bp.register_blueprint(monitor_bp)
-
-
-def register_routes(app: Quart):
- # Register database state models
- # to api blueprint i.e. /api_v1/session, /api_v1/task & /api_v1/candidate
- register_state_models(backend_bp)
-
- app.register_blueprint(backend_bp)
- app.register_blueprint(frontend_bp)
-
-
-__all__ = ["register_routes"]
+from quart import Blueprint, Quart
+
+from .art_preview import art_blueprint
+from .config import config_bp
+from .db_models import register_state_models
+from .exception import error_bp
+from .frontend import frontend_bp
+from .inbox import inbox_bp
+from .library import library_bp
+from .monitor import monitor_bp
+
+backend_bp = Blueprint("backend", __name__, url_prefix="/api_v1")
+
+# Register all backend blueprints
+backend_bp.register_blueprint(art_blueprint)
+backend_bp.register_blueprint(config_bp)
+backend_bp.register_blueprint(error_bp)
+backend_bp.register_blueprint(frontend_bp)
+backend_bp.register_blueprint(inbox_bp)
+backend_bp.register_blueprint(library_bp)
+backend_bp.register_blueprint(monitor_bp)
+
+
+def register_routes(app: Quart):
+ # Register database state models
+ # to api blueprint i.e. /api_v1/session, /api_v1/task & /api_v1/candidate
+ register_state_models(backend_bp)
+
+ app.register_blueprint(backend_bp)
+ app.register_blueprint(frontend_bp)
+
+
+__all__ = ["register_routes"]
diff --git a/backend/beets_flask/server/routes/art_preview.py b/backend/beets_flask/server/routes/art_preview.py
index f932d8c6..6988a065 100644
--- a/backend/beets_flask/server/routes/art_preview.py
+++ b/backend/beets_flask/server/routes/art_preview.py
@@ -1,109 +1,109 @@
-"""Fetch art via ids.
-
-Allows fetching of art via different ids. At the moment we support:
-- Spotify album ids (if spotify plugin is enabled)
-"""
-
-import os
-from urllib.parse import quote_plus
-
-import aiohttp
-from quart import Blueprint, jsonify, redirect, request, url_for
-
-from beets_flask.logger import log
-from beets_flask.utility import AUDIO_EXTENSIONS
-
-art_blueprint = Blueprint("art", __name__, url_prefix="/art")
-
-
-@art_blueprint.route("", methods=["GET"])
-async def redirect_external_art():
- """Get Spotify album art."""
-
- # Check that url query param is set
- url = request.args.get("url")
- if not url:
- return jsonify({"error": "url query param is required."}), 400
-
- # Check that url is a valid spotify url
- redirect_url: str | None = None
- if "spotify" in url:
- redirect_url = await get_spotify_art(url)
- elif "musicbrainz" in url:
- redirect_url = await get_musicbrainz_art(url)
- elif url.startswith("file://"):
- return await get_folder_art(url)
-
- if redirect_url:
- return redirect(redirect_url, code=302)
- else:
- return jsonify({"error": "No art found."}), 404
-
-
-async def get_spotify_art(url: str) -> str | None:
- """Uses spotify oembed to redirect to the album art.
-
- See https://developer.spotify.com/documentation/embeds/reference/oembed
-
- Returns the url the the art.
- """
- print(f"https://embed.spotify.com/oembed?url={quote_plus(url)}")
- async with aiohttp.ClientSession() as session:
- async with session.get(
- f"https://embed.spotify.com/oembed?url={quote_plus(url)}"
- ) as response:
- if response.status == 200:
- data = await response.json()
- return data.get("thumbnail_url")
- else:
- log.error(f"Error fetching Spotify art: {response.status}")
- return None
-
-
-async def get_musicbrainz_art(url: str) -> str | None:
- """Uses musicbrainz oembed to redirect to the album art.
-
- See https://musicbrainz.org/doc/Cover_Art_Archive/API
-
- Returns the url the the art.
- """
-
- # Extract the release id from the url
- # musicbrainz urls look like this:
- # https://musicbrainz.org/release/2b5f7e4d-2a1c-4f6d-8a0c-7b8b9e3a1f3f
- release_id = url.split("/")[-1]
-
- return f"https://coverartarchive.org/release/{release_id}/front-250"
-
-
-async def get_folder_art(url: str):
- """Infers the folder art from a given file path.
-
- This is a bit of a hack, but it works for now.
- url="file:///path/to/music/folder"
- """
-
- # Check first file for and embedded cover art
- path = url.split("file://")[-1]
- print(path)
- # Check if exists
- if not os.path.exists(path):
- return jsonify({"error": f"Path '{path}' does not exist."}), 404
-
- # Get first file in folder
- files = [
- f
- for f in os.listdir(path)
- if f.endswith(tuple(["." + e for e in AUDIO_EXTENSIONS]))
- ]
- if not files or len(files) < 1:
- return jsonify({"error": "No audio files found in folder."}), 404
-
- # Redirect to file art endpoint /file//art
- return redirect(
- url_for(
- "backend.library.artwork.file_art",
- filepath=quote_plus(path + "/" + files[0]),
- ),
- code=302,
- )
+"""Fetch art via ids.
+
+Allows fetching of art via different ids. At the moment we support:
+- Spotify album ids (if spotify plugin is enabled)
+"""
+
+import os
+from urllib.parse import quote_plus
+
+import aiohttp
+from quart import Blueprint, jsonify, redirect, request, url_for
+
+from beets_flask.logger import log
+from beets_flask.utility import AUDIO_EXTENSIONS
+
+art_blueprint = Blueprint("art", __name__, url_prefix="/art")
+
+
+@art_blueprint.route("", methods=["GET"])
+async def redirect_external_art():
+ """Get Spotify album art."""
+
+ # Check that url query param is set
+ url = request.args.get("url")
+ if not url:
+ return jsonify({"error": "url query param is required."}), 400
+
+ # Check that url is a valid spotify url
+ redirect_url: str | None = None
+ if "spotify" in url:
+ redirect_url = await get_spotify_art(url)
+ elif "musicbrainz" in url:
+ redirect_url = await get_musicbrainz_art(url)
+ elif url.startswith("file://"):
+ return await get_folder_art(url)
+
+ if redirect_url:
+ return redirect(redirect_url, code=302)
+ else:
+ return jsonify({"error": "No art found."}), 404
+
+
+async def get_spotify_art(url: str) -> str | None:
+ """Uses spotify oembed to redirect to the album art.
+
+ See https://developer.spotify.com/documentation/embeds/reference/oembed
+
+ Returns the url the the art.
+ """
+ print(f"https://embed.spotify.com/oembed?url={quote_plus(url)}")
+ async with aiohttp.ClientSession() as session:
+ async with session.get(
+ f"https://embed.spotify.com/oembed?url={quote_plus(url)}"
+ ) as response:
+ if response.status == 200:
+ data = await response.json()
+ return data.get("thumbnail_url")
+ else:
+ log.error(f"Error fetching Spotify art: {response.status}")
+ return None
+
+
+async def get_musicbrainz_art(url: str) -> str | None:
+ """Uses musicbrainz oembed to redirect to the album art.
+
+ See https://musicbrainz.org/doc/Cover_Art_Archive/API
+
+ Returns the url the the art.
+ """
+
+ # Extract the release id from the url
+ # musicbrainz urls look like this:
+ # https://musicbrainz.org/release/2b5f7e4d-2a1c-4f6d-8a0c-7b8b9e3a1f3f
+ release_id = url.split("/")[-1]
+
+ return f"https://coverartarchive.org/release/{release_id}/front-250"
+
+
+async def get_folder_art(url: str):
+ """Infers the folder art from a given file path.
+
+ This is a bit of a hack, but it works for now.
+ url="file:///path/to/music/folder"
+ """
+
+ # Check first file for and embedded cover art
+ path = url.split("file://")[-1]
+ print(path)
+ # Check if exists
+ if not os.path.exists(path):
+ return jsonify({"error": f"Path '{path}' does not exist."}), 404
+
+ # Get first file in folder
+ files = [
+ f
+ for f in os.listdir(path)
+ if f.endswith(tuple(["." + e for e in AUDIO_EXTENSIONS]))
+ ]
+ if not files or len(files) < 1:
+ return jsonify({"error": "No audio files found in folder."}), 404
+
+ # Redirect to file art endpoint /file//art
+ return redirect(
+ url_for(
+ "backend.library.artwork.file_art",
+ filepath=quote_plus(path + "/" + files[0]),
+ ),
+ code=302,
+ )
diff --git a/backend/beets_flask/server/routes/config.py b/backend/beets_flask/server/routes/config.py
index 42f54b6b..4d84fcd5 100644
--- a/backend/beets_flask/server/routes/config.py
+++ b/backend/beets_flask/server/routes/config.py
@@ -1,112 +1,112 @@
-"""Config endpoints for the frontend.
-
-we need some of our settings in the frontend. proposed solution:
-fetch settings once from the backend on first page-load.
-"""
-
-from beets import __version__ as beets_version
-from quart import Blueprint, jsonify
-
-from beets_flask.config import get_config
-
-config_bp = Blueprint("config", __name__, url_prefix="/config")
-
-
-@config_bp.route("/all", methods=["GET"])
-async def get_all():
- """Get nested dict representing the full (but redacted) beets config."""
- config = get_config()
- return jsonify(_serializable(config.flatten(redact=True)))
-
-
-@config_bp.route("/", methods=["GET"])
-async def get_basic():
- """Get the config settings needed for the gui."""
- config = get_config()
- plugins = config["plugins"].as_str_seq()
- from beets.metadata_plugins import find_metadata_source_plugins
-
- data_sources: list[str] = [
- p.__class__.data_source for p in find_metadata_source_plugins()
- ]
-
- return jsonify(
- {
- "gui": _serializable(config["gui"].flatten(redact=True)),
- "import": {
- k: config["import"][k].get()
- for k in [
- "duplicate_action",
- ]
- },
- "match": {
- k: config["match"][k].get()
- for k in [
- "strong_rec_thresh",
- "medium_rec_thresh",
- ]
- }
- | {
- k: config["match"][k].as_str_seq()
- for k in [
- "album_disambig_fields",
- "singleton_disambig_fields",
- ]
- },
- "plugins": config["plugins"].as_str_seq(),
- "data_sources": data_sources,
- "beets_version": beets_version,
- }
- )
-
-
-@config_bp.route("/yaml/beets", methods=["GET"])
-async def get_raw_beets():
- """Get the raw config yaml file."""
- config = get_config()
- path = config.get_beets_config_path()
- with open(path) as f:
- content = f.read()
- return jsonify({"path": path, "content": content})
-
-
-@config_bp.route("/yaml", methods=["GET"])
-async def get_raw():
- """Get the raw config yaml file for beets-flask."""
- config = get_config()
- path = config.get_beets_flask_config_path()
- with open(path) as f:
- content = f.read()
- return jsonify({"path": path, "content": content})
-
-
-@config_bp.route("/refresh", methods=["POST"])
-async def refresh():
- """Refresh the config object.
-
- Mainly for debug purposes as it only refresh the config for
- the main thread.
-
- ```
- curl -X POST http://localhost:5001/api_v1/config/refresh
- ```
- """
- from beets_flask.config.beets_config import refresh_config
-
- refresh_config()
- return jsonify({"status": "ok"})
-
-
-def _serializable(input):
- """
- Convert bytes to str in a nested dictionary.
-
- Recursion is used to handle nested dictionaries.
- """
- if isinstance(input, bytes):
- return input.decode("utf-8")
- elif isinstance(input, dict):
- return {k: _serializable(v) for k, v in input.items()}
- elif isinstance(input, list):
- return [_serializable(element) for element in input]
- return input
+"""Config endpoints for the frontend.
+
+we need some of our settings in the frontend. proposed solution:
+fetch settings once from the backend on first page-load.
+"""
+
+from beets import __version__ as beets_version
+from quart import Blueprint, jsonify
+
+from beets_flask.config import get_config
+
+config_bp = Blueprint("config", __name__, url_prefix="/config")
+
+
+@config_bp.route("/all", methods=["GET"])
+async def get_all():
+ """Get nested dict representing the full (but redacted) beets config."""
+ config = get_config()
+ return jsonify(_serializable(config.flatten(redact=True)))
+
+
+@config_bp.route("/", methods=["GET"])
+async def get_basic():
+ """Get the config settings needed for the gui."""
+ config = get_config()
+ plugins = config["plugins"].as_str_seq()
+ from beets.metadata_plugins import find_metadata_source_plugins
+
+ data_sources: list[str] = [
+ p.__class__.data_source for p in find_metadata_source_plugins()
+ ]
+
+ return jsonify(
+ {
+ "gui": _serializable(config["gui"].flatten(redact=True)),
+ "import": {
+ k: config["import"][k].get()
+ for k in [
+ "duplicate_action",
+ ]
+ },
+ "match": {
+ k: config["match"][k].get()
+ for k in [
+ "strong_rec_thresh",
+ "medium_rec_thresh",
+ ]
+ }
+ | {
+ k: config["match"][k].as_str_seq()
+ for k in [
+ "album_disambig_fields",
+ "singleton_disambig_fields",
+ ]
+ },
+ "plugins": config["plugins"].as_str_seq(),
+ "data_sources": data_sources,
+ "beets_version": beets_version,
+ }
+ )
+
+
+@config_bp.route("/yaml/beets", methods=["GET"])
+async def get_raw_beets():
+ """Get the raw config yaml file."""
+ config = get_config()
+ path = config.get_beets_config_path()
+ with open(path) as f:
+ content = f.read()
+ return jsonify({"path": path, "content": content})
+
+
+@config_bp.route("/yaml", methods=["GET"])
+async def get_raw():
+ """Get the raw config yaml file for beets-flask."""
+ config = get_config()
+ path = config.get_beets_flask_config_path()
+ with open(path) as f:
+ content = f.read()
+ return jsonify({"path": path, "content": content})
+
+
+@config_bp.route("/refresh", methods=["POST"])
+async def refresh():
+ """Refresh the config object.
+
+ Mainly for debug purposes as it only refresh the config for
+ the main thread.
+
+ ```
+ curl -X POST http://localhost:5001/api_v1/config/refresh
+ ```
+ """
+ from beets_flask.config.beets_config import refresh_config
+
+ refresh_config()
+ return jsonify({"status": "ok"})
+
+
+def _serializable(input):
+ """
+ Convert bytes to str in a nested dictionary.
+
+ Recursion is used to handle nested dictionaries.
+ """
+ if isinstance(input, bytes):
+ return input.decode("utf-8")
+ elif isinstance(input, dict):
+ return {k: _serializable(v) for k, v in input.items()}
+ elif isinstance(input, list):
+ return [_serializable(element) for element in input]
+ return input
diff --git a/backend/beets_flask/server/routes/db_models/__init__.py b/backend/beets_flask/server/routes/db_models/__init__.py
index eb2b23c1..e7c0a01c 100644
--- a/backend/beets_flask/server/routes/db_models/__init__.py
+++ b/backend/beets_flask/server/routes/db_models/__init__.py
@@ -1,28 +1,28 @@
-from quart import Blueprint, Quart
-
-from beets_flask.database.models.states import CandidateStateInDb, TaskStateInDb
-
-from .base import ModelAPIBlueprint
-from .folder import FolderAPIBlueprint
-from .session import SessionAPIBlueprint
-
-
-def register_state_models(app: Blueprint | Quart):
- # Session is a special case and implements some more logic
- app.register_blueprint(SessionAPIBlueprint().blueprint)
- app.register_blueprint(FolderAPIBlueprint().blueprint)
-
- # It is not really used in the frontend but for future
- # reference we might want to use it
- app.register_blueprint(
- ModelAPIBlueprint(
- TaskStateInDb,
- url_prefix="/task",
- ).blueprint
- )
- app.register_blueprint(
- ModelAPIBlueprint(
- CandidateStateInDb,
- url_prefix="/candidate",
- ).blueprint
- )
+from quart import Blueprint, Quart
+
+from beets_flask.database.models.states import CandidateStateInDb, TaskStateInDb
+
+from .base import ModelAPIBlueprint
+from .folder import FolderAPIBlueprint
+from .session import SessionAPIBlueprint
+
+
+def register_state_models(app: Blueprint | Quart):
+ # Session is a special case and implements some more logic
+ app.register_blueprint(SessionAPIBlueprint().blueprint)
+ app.register_blueprint(FolderAPIBlueprint().blueprint)
+
+ # It is not really used in the frontend but for future
+ # reference we might want to use it
+ app.register_blueprint(
+ ModelAPIBlueprint(
+ TaskStateInDb,
+ url_prefix="/task",
+ ).blueprint
+ )
+ app.register_blueprint(
+ ModelAPIBlueprint(
+ CandidateStateInDb,
+ url_prefix="/candidate",
+ ).blueprint
+ )
diff --git a/backend/beets_flask/server/routes/db_models/base.py b/backend/beets_flask/server/routes/db_models/base.py
index 9200dc9c..d424c9c9 100644
--- a/backend/beets_flask/server/routes/db_models/base.py
+++ b/backend/beets_flask/server/routes/db_models/base.py
@@ -1,143 +1,143 @@
-from collections.abc import Sequence
-from datetime import datetime
-from typing import Generic, TypeVar
-
-from quart import Blueprint, request
-from sqlalchemy import select
-
-from beets_flask.database import db_session_factory
-from beets_flask.database.models.base import Base
-from beets_flask.server.routes.exception import InvalidUsageException
-from beets_flask.server.utility import pop_query_param
-
-__all__ = ["ModelAPIBlueprint"]
-
-T = TypeVar("T", bound=Base)
-
-
-class ModelAPIBlueprint(Generic[T]):
- """Generic API blueprint for a model.
-
- Any database model can be used with this blueprint. Allows
- for easy CRUD operations on the model.
- """
-
- blueprint: Blueprint
- model: type[T]
-
- def __init__(self, model: type[T], url_prefix: str | None = None):
- # Use the model name as the default URL prefix
- if url_prefix is None:
- url_prefix = model.__name__.lower()
-
- self.model = model
- self.blueprint = Blueprint(
- url_prefix,
- __name__,
- url_prefix=url_prefix,
- )
-
- self._register_routes()
-
- def _register_routes(self) -> None:
- """Register the routes for the blueprint."""
- self.blueprint.route("/", methods=["GET"])(self.get_all)
- self.blueprint.route("/id/", methods=["GET"])(self.get_by_id)
- self.blueprint.route("/id/", methods=["DELETE"])(self.delete_by_id)
-
- async def get_all(self):
- params = dict(request.args)
- # Cursor is encoded as a string in the format "datetime,id" where date
- # is the creation date as integer and id is the id of the item.
- cursor = pop_query_param(
- params,
- "cursor",
- _cursor_from_string,
- )
- n_items = pop_query_param(
- params,
- "n_items",
- int,
- 50,
- )
-
- items, next_cursor = _get_n_with_cursor(self.model, cursor, n_items)
-
- cursor_str = _cursor_to_string(next_cursor)
-
- if cursor_str is not None:
- next = f"{request.path}?cursor={cursor_str}&n_items={n_items}"
- else:
- next = None
- return {"items": items, "next": next}
-
- async def get_by_id(self, id: str):
- with db_session_factory() as session:
- item = self.model.get_by(self.model.id == id, session=session)
- if not item:
- raise InvalidUsageException(
- f"Item with id {id} not found", status_code=404
- )
-
- return item.to_dict()
-
- async def delete_by_id(self, id: str):
- with db_session_factory() as session:
- item = self.model.get_by(self.model.id == id, session=session)
- if not item:
- return {"message": f"Item with id {id} not found"}, 200
- session.delete(item)
- session.commit()
-
- return {"message": f"Item with id {id} deleted successfully"}, 200
-
-
-# ------------------------------- Local Utility ------------------------------ #
-
-
-def _cursor_to_string(cursor: tuple[datetime, str] | None) -> str | None:
- if cursor is None:
- return None
- return f"{cursor[0].isoformat()},{cursor[1]}".encode().hex()
-
-
-def _cursor_from_string(cursor: str | None) -> tuple[datetime, str] | None:
- if cursor is None:
- return None
- cursor = bytes.fromhex(cursor).decode("utf-8")
- c = cursor.split(",")
- if len(c) != 2:
- return None
- return datetime.fromisoformat(c[0]), c[1]
-
-
-def _get_n_with_cursor(
- model: type[T], cursor: tuple[datetime, str] | None = None, n_items: int = 50
-):
- """Seek pagination for all items in the database.
-
- Returns a list of items and a cursor for the next page.
- """
-
- with db_session_factory() as db_session:
- query = select(model)
- if cursor:
- query = query.where(
- # cursor is a combination of date and model id, this is faster than
- # just using an offset
- (model.created_at <= cursor[0]).__and__(model.id < cursor[1])
- )
- query = query.order_by(model.created_at.desc(), model.id.desc()).limit(n_items)
- items: Sequence[T] = db_session.execute(query).scalars().all()
-
- # Convert items to a list of dictionaries
- items_list = [item.to_dict() for item in items]
-
- # Determine the next cursor
- if len(items) == n_items:
- last_item = items[-1]
- next_cursor = (last_item.created_at, last_item.id)
- else:
- next_cursor = None
-
- return items_list, next_cursor
+from collections.abc import Sequence
+from datetime import datetime
+from typing import Generic, TypeVar
+
+from quart import Blueprint, request
+from sqlalchemy import select
+
+from beets_flask.database import db_session_factory
+from beets_flask.database.models.base import Base
+from beets_flask.server.routes.exception import InvalidUsageException
+from beets_flask.server.utility import pop_query_param
+
+__all__ = ["ModelAPIBlueprint"]
+
+T = TypeVar("T", bound=Base)
+
+
+class ModelAPIBlueprint(Generic[T]):
+ """Generic API blueprint for a model.
+
+ Any database model can be used with this blueprint. Allows
+ for easy CRUD operations on the model.
+ """
+
+ blueprint: Blueprint
+ model: type[T]
+
+ def __init__(self, model: type[T], url_prefix: str | None = None):
+ # Use the model name as the default URL prefix
+ if url_prefix is None:
+ url_prefix = model.__name__.lower()
+
+ self.model = model
+ self.blueprint = Blueprint(
+ url_prefix,
+ __name__,
+ url_prefix=url_prefix,
+ )
+
+ self._register_routes()
+
+ def _register_routes(self) -> None:
+ """Register the routes for the blueprint."""
+ self.blueprint.route("/", methods=["GET"])(self.get_all)
+ self.blueprint.route("/id/", methods=["GET"])(self.get_by_id)
+ self.blueprint.route("/id/", methods=["DELETE"])(self.delete_by_id)
+
+ async def get_all(self):
+ params = dict(request.args)
+ # Cursor is encoded as a string in the format "datetime,id" where date
+ # is the creation date as integer and id is the id of the item.
+ cursor = pop_query_param(
+ params,
+ "cursor",
+ _cursor_from_string,
+ )
+ n_items = pop_query_param(
+ params,
+ "n_items",
+ int,
+ 50,
+ )
+
+ items, next_cursor = _get_n_with_cursor(self.model, cursor, n_items)
+
+ cursor_str = _cursor_to_string(next_cursor)
+
+ if cursor_str is not None:
+ next = f"{request.path}?cursor={cursor_str}&n_items={n_items}"
+ else:
+ next = None
+ return {"items": items, "next": next}
+
+ async def get_by_id(self, id: str):
+ with db_session_factory() as session:
+ item = self.model.get_by(self.model.id == id, session=session)
+ if not item:
+ raise InvalidUsageException(
+ f"Item with id {id} not found", status_code=404
+ )
+
+ return item.to_dict()
+
+ async def delete_by_id(self, id: str):
+ with db_session_factory() as session:
+ item = self.model.get_by(self.model.id == id, session=session)
+ if not item:
+ return {"message": f"Item with id {id} not found"}, 200
+ session.delete(item)
+ session.commit()
+
+ return {"message": f"Item with id {id} deleted successfully"}, 200
+
+
+# ------------------------------- Local Utility ------------------------------ #
+
+
+def _cursor_to_string(cursor: tuple[datetime, str] | None) -> str | None:
+ if cursor is None:
+ return None
+ return f"{cursor[0].isoformat()},{cursor[1]}".encode().hex()
+
+
+def _cursor_from_string(cursor: str | None) -> tuple[datetime, str] | None:
+ if cursor is None:
+ return None
+ cursor = bytes.fromhex(cursor).decode("utf-8")
+ c = cursor.split(",")
+ if len(c) != 2:
+ return None
+ return datetime.fromisoformat(c[0]), c[1]
+
+
+def _get_n_with_cursor(
+ model: type[T], cursor: tuple[datetime, str] | None = None, n_items: int = 50
+):
+ """Seek pagination for all items in the database.
+
+ Returns a list of items and a cursor for the next page.
+ """
+
+ with db_session_factory() as db_session:
+ query = select(model)
+ if cursor:
+ query = query.where(
+ # cursor is a combination of date and model id, this is faster than
+ # just using an offset
+ (model.created_at <= cursor[0]).__and__(model.id < cursor[1])
+ )
+ query = query.order_by(model.created_at.desc(), model.id.desc()).limit(n_items)
+ items: Sequence[T] = db_session.execute(query).scalars().all()
+
+ # Convert items to a list of dictionaries
+ items_list = [item.to_dict() for item in items]
+
+ # Determine the next cursor
+ if len(items) == n_items:
+ last_item = items[-1]
+ next_cursor = (last_item.created_at, last_item.id)
+ else:
+ next_cursor = None
+
+ return items_list, next_cursor
diff --git a/backend/beets_flask/server/routes/db_models/folder.py b/backend/beets_flask/server/routes/db_models/folder.py
index cee3a7d8..6f1b4302 100644
--- a/backend/beets_flask/server/routes/db_models/folder.py
+++ b/backend/beets_flask/server/routes/db_models/folder.py
@@ -1,42 +1,42 @@
-from sqlalchemy import select
-
-from beets_flask.database import db_session_factory
-from beets_flask.database.models import FolderInDb, SessionStateInDb, TaskStateInDb
-from beets_flask.server.exceptions import NotFoundException
-
-from .base import ModelAPIBlueprint
-
-
-class FolderAPIBlueprint(ModelAPIBlueprint[FolderInDb]):
- def __init__(self):
- super().__init__(
- model=FolderInDb,
- url_prefix="/dbfolder",
- )
-
- def _register_routes(self):
- super()._register_routes()
- # Register any additional routes specific to Folder here
- self.blueprint.route("/by_task/", methods=["GET"])(self.get_by_taskid)
-
- async def get_by_taskid(self, gui_id: str):
- """
- Get a folder by an import gui id.
-
- The import gui id is the same as a task id.
- """
- with db_session_factory() as db_session:
- stmt = (
- select(FolderInDb)
- .join(SessionStateInDb, TaskStateInDb.session_id == SessionStateInDb.id)
- .join(TaskStateInDb, FolderInDb.id == SessionStateInDb.folder_hash)
- .where(
- TaskStateInDb.id == gui_id,
- )
- )
- folder = db_session.execute(stmt).scalars().first()
-
- if folder is None:
- raise NotFoundException("Folder not found")
-
- return folder.to_dict()
+from sqlalchemy import select
+
+from beets_flask.database import db_session_factory
+from beets_flask.database.models import FolderInDb, SessionStateInDb, TaskStateInDb
+from beets_flask.server.exceptions import NotFoundException
+
+from .base import ModelAPIBlueprint
+
+
+class FolderAPIBlueprint(ModelAPIBlueprint[FolderInDb]):
+ def __init__(self):
+ super().__init__(
+ model=FolderInDb,
+ url_prefix="/dbfolder",
+ )
+
+ def _register_routes(self):
+ super()._register_routes()
+ # Register any additional routes specific to Folder here
+ self.blueprint.route("/by_task/", methods=["GET"])(self.get_by_taskid)
+
+ async def get_by_taskid(self, gui_id: str):
+ """
+ Get a folder by an import gui id.
+
+ The import gui id is the same as a task id.
+ """
+ with db_session_factory() as db_session:
+ stmt = (
+ select(FolderInDb)
+ .join(SessionStateInDb, TaskStateInDb.session_id == SessionStateInDb.id)
+ .join(TaskStateInDb, FolderInDb.id == SessionStateInDb.folder_hash)
+ .where(
+ TaskStateInDb.id == gui_id,
+ )
+ )
+ folder = db_session.execute(stmt).scalars().first()
+
+ if folder is None:
+ raise NotFoundException("Folder not found")
+
+ return folder.to_dict()
diff --git a/backend/beets_flask/server/routes/db_models/session.py b/backend/beets_flask/server/routes/db_models/session.py
index 428f5b04..635bac16 100644
--- a/backend/beets_flask/server/routes/db_models/session.py
+++ b/backend/beets_flask/server/routes/db_models/session.py
@@ -1,410 +1,410 @@
-from __future__ import annotations
-
-from datetime import datetime, timedelta
-
-from quart import jsonify, request
-from rq.job import Job
-from sqlalchemy import select
-
-from beets_flask import invoker
-from beets_flask.database import db_session_factory
-from beets_flask.database.models.states import (
- FolderInDb,
- SessionStateInDb,
- TaskStateInDb,
-)
-from beets_flask.importer.progress import FolderStatus, Progress
-from beets_flask.logger import log
-from beets_flask.server.exceptions import (
- InvalidUsageException,
- NotFoundException,
- SerializedException,
-)
-from beets_flask.server.utility import (
- pop_extra_meta,
- pop_folder_params,
- pop_query_param,
-)
-from beets_flask.server.websocket.status import FolderStatusUpdate, JobStatusUpdate
-
-from .base import ModelAPIBlueprint
-
-__all__ = ["SessionAPIBlueprint"]
-
-
-class SessionAPIBlueprint(ModelAPIBlueprint[SessionStateInDb]):
- def __init__(self):
- super().__init__(SessionStateInDb, url_prefix="/session")
-
- def _register_routes(self) -> None:
- """Register the routes for the blueprint."""
- super()._register_routes()
- self.blueprint.route("/by_folder", methods=["POST"])(self.get_by_folder)
- self.blueprint.route("/status", methods=["GET"])(self.get_status)
- self.blueprint.route("/enqueue", methods=["POST"])(self.enqueue)
- self.blueprint.route("/add_candidates", methods=["POST"])(self.add_candidates)
-
- async def get_by_folder(self):
- """Returns the most recent session state for a given folder hash or path."""
-
- params = await request.get_json()
- folder_hashes, folder_paths = pop_folder_params(params, allow_mismatch=True)
-
- if len(folder_hashes) != 1 and len(folder_paths) != 1:
- raise InvalidUsageException(
- "Provide one folder hash OR one folder path", status_code=400
- )
-
- with db_session_factory() as db_session:
- item = self.model.get_by_hash_and_path(
- hash=folder_hashes[0],
- path=folder_paths[0],
- db_session=db_session,
- )
-
- if not item:
- # TODO: by path, validation of session hash
- # raise, but we do not want to spam the
- # frontend console with errors.
- # we manually handle this in sessionQueryOptions.
- raise NotFoundException(
- f"Item with {folder_hashes=} {folder_paths=} not found",
- status_code=200,
- )
-
- return jsonify(item.to_dict())
-
- async def enqueue(self):
- """Start a new session for a given folder hash or enqueue a new job for an existing session.
-
- You need to specify the folder of the album,
- and it has to be a valid album folder.
-
- # Params
- - `kind` (str): The kind of the tag. See `invoker.EnqueueKind`.
-
- """
- params = await request.get_json()
- folder_hashes, folder_paths = pop_folder_params(params)
- kind = pop_query_param(params, "kind", str)
- if not isinstance(kind, str):
- raise InvalidUsageException(
- "kind must be one of " + str(invoker.EnqueueKind.__members__)
- )
-
- extra_meta = pop_extra_meta(params, n_jobs=len(folder_hashes))
-
- jobs: list[Job] = []
-
- for hash, path, meta in zip(folder_hashes, folder_paths, extra_meta):
- jobs.append(
- await invoker.enqueue(
- hash,
- str(path),
- invoker.EnqueueKind.from_str(kind),
- extra_meta=meta,
- **params,
- )
- )
-
- return jsonify(
- JobStatusUpdate(
- message=f"{len(jobs)} added as kind: {kind}",
- num_jobs=len(jobs),
- job_metas=[j.get_meta() for j in jobs], # type: ignore
- )
- )
-
- async def add_candidates(self):
- """Search for new candidates.
-
- Helper function which is pretty similar to enqueue. But only allows for a single
- folder hash and path.
- """
- params = await request.get_json()
- task_id = pop_query_param(params, "task_id", str)
- session_id = pop_query_param(params, "session_id", str)
-
- folder_hash: str | None = None
- folder_path: str | None = None
- with db_session_factory() as db_session:
- # Get path, hash by task_id
- if session_id is not None:
- stmt_session = select(SessionStateInDb).where(
- SessionStateInDb.id == session_id
- )
- session_indb = db_session.execute(stmt_session).scalar_one_or_none()
- if session_indb is None:
- raise InvalidUsageException(
- f"Session with session_id {session_id} not found",
- )
-
- folder_path = session_indb.folder.full_path
- folder_hash = session_indb.folder.hash
-
- if task_id is not None:
- stmt_task = select(TaskStateInDb).where(TaskStateInDb.id == task_id)
- task_indb = db_session.execute(stmt_task).scalar_one_or_none()
- if task_indb is None:
- raise InvalidUsageException(
- f"Task with task_id {task_id} not found",
- )
-
- folder_path = task_indb.session.folder.full_path
- folder_hash = task_indb.session.folder.hash
-
- if folder_hash is None or folder_path is None:
- raise InvalidUsageException(
- "task_id or session_id must be provided",
- )
-
- extra_meta = pop_extra_meta(params, n_jobs=1)
-
- job = await invoker.enqueue(
- folder_hash,
- folder_path,
- invoker.EnqueueKind.PREVIEW_ADD_CANDIDATES,
- extra_meta=extra_meta[0],
- **params,
- )
-
- return jsonify(
- JobStatusUpdate(
- message=f"searching_candidates for {folder_path} folders",
- num_jobs=1,
- job_metas=[job.get_meta()], # type: ignore
- )
- )
-
- async def get_status(self):
- """Get all pending tasks."""
-
- params = await request.get_json()
- folder_hashes, folder_paths = pop_folder_params(params)
-
- stats: list[FolderStatusUpdate] = []
-
- if len(folder_hashes) == 0:
- stmt = select(FolderInDb).order_by(FolderInDb.created_at.desc())
- with db_session_factory() as session:
- folders = session.execute(stmt).scalars().all()
- folder_hashes = [f.hash for f in folders]
- folder_paths = [f.full_path for f in folders]
-
- log.debug(f"Checking status for {len(folder_hashes)} folders")
-
- for hash, path in zip(folder_hashes, folder_paths):
- log.debug(f"Checking folder status via session from db: {path} ({hash})")
- db_status, db_date, db_exc = _get_folder_status_from_db(hash)
- log.debug(f"Found {db_status=} {db_date=} {db_exc=}")
-
- log.debug(f"Checking folder status via job queues: {path} ({hash})")
- job_status, job_date, job_exc = _get_folder_status_from_queues(hash)
- log.debug(f"Found {job_status=} {job_date=} {job_exc=}")
-
- # just for None casting, timezones prevent comparing
- if db_date is not None:
- db_date = db_date.replace(tzinfo=None)
- if job_date is not None:
- job_date = job_date.replace(tzinfo=None)
-
- status = FolderStatus.UNKNOWN
- exc = None
- if db_date is None and job_date is None:
- pass
- elif (db_date or datetime.min) + timedelta(seconds=1) >= (
- job_date or datetime.min
- ):
- # Sometimes, the job_date might be some .7secs after db_date and would
- # get favoured, so we added a second of leeway.
- log.debug(f"Using status from DB: {db_date} >= {job_date}")
- status = db_status
- exc = db_exc
- else:
- log.debug(f"Using status from job queue : {db_date} < {job_date}")
- status = job_status
- exc = job_exc
-
- stats.append(
- FolderStatusUpdate(path=str(path), hash=hash, status=status, exc=exc)
- )
-
- return jsonify(stats)
-
-
-def _get_folder_status_from_db(
- hash: str,
-) -> tuple[FolderStatus, datetime | None, SerializedException | None]:
- with db_session_factory() as db_session:
- stmt_s = (
- select(SessionStateInDb)
- .where(SessionStateInDb.folder_hash == hash)
- .order_by(SessionStateInDb.folder_revision.desc())
- )
- s_state_indb = db_session.execute(stmt_s).scalars().first()
- if s_state_indb is None:
- return FolderStatus.UNKNOWN, None, None
- else:
- # PS: This progress <-> state mapping feels inconsistent.
- # There should be a better place for this.
- status = FolderStatus.UNKNOWN
- if s_state_indb.progress == Progress.NOT_STARTED:
- status = FolderStatus.NOT_STARTED
- elif s_state_indb.progress == Progress.DELETING:
- status = FolderStatus.DELETING
- elif s_state_indb.progress == Progress.DELETION_COMPLETED:
- status = FolderStatus.DELETED
- elif s_state_indb.progress == Progress.PREVIEW_COMPLETED:
- status = FolderStatus.PREVIEWED
- elif s_state_indb.progress == Progress.IMPORT_COMPLETED:
- status = FolderStatus.IMPORTED
- elif s_state_indb.progress < Progress.PREVIEW_COMPLETED:
- status = FolderStatus.PREVIEWING
- elif s_state_indb.progress < Progress.IMPORT_COMPLETED:
- status = FolderStatus.IMPORTING
-
- if s_state_indb.exception is not None:
- exc = s_state_indb.exception
- status = FolderStatus.FAILED
- else:
- exc = None
-
- return status, s_state_indb.updated_at, exc
-
-
-def _get_folder_status_from_queues(
- hash: str,
-) -> tuple[FolderStatus, datetime | None, SerializedException | None]:
- from beets_flask.redis import queues, redis_conn
-
- # could not simply import queues from beets_flask.redis ?
- # queues = [import_queue, preview_queue]
-
- # hold a list of jobs, sorted by the queue/job status
- q_kinds: dict[str, list[Job]] = {
- "queued": [],
- "scheduled": [],
- "started": [],
- "failed": [],
- "finished": [],
- }
-
- for q in queues:
- q_kinds["queued"].extend(_get_jobs(q, connection=redis_conn))
- q_kinds["scheduled"].extend(
- _get_jobs(q.scheduled_job_registry, connection=redis_conn)
- )
- q_kinds["started"].extend(
- _get_jobs(q.started_job_registry, connection=redis_conn)
- )
- q_kinds["failed"].extend(
- _get_jobs(q.failed_job_registry, connection=redis_conn)
- )
- q_kinds["finished"].extend(
- _get_jobs(q.finished_job_registry, connection=redis_conn)
- )
-
- # We always want the latest info, no matter from which queue.
- job_date = None
- status = FolderStatus.UNKNOWN
- exc = None
-
- for kind in q_kinds.keys():
- jobs = q_kinds[kind]
-
- meta_job_date = _is_hash_in_jobs(hash, jobs)
- if meta_job_date is None:
- # Hash not found
- continue
-
- meta, job, _job_date = meta_job_date
- if job_date is None or _job_date > job_date:
- job_date = _job_date
- else:
- # Job is not newer than from other queue
- continue
-
- if kind in ["queued", "scheduled"]:
- status = FolderStatus.PENDING
- elif kind == "failed":
- status = FolderStatus.FAILED
- elif kind == "started":
- if "import" in meta["job_kind"]:
- status = FolderStatus.IMPORTING
- elif "preview" in meta["job_kind"]:
- status = FolderStatus.PREVIEWING
- else:
- raise ValueError("Unknown job kind")
- elif kind == "finished":
- if "import" in meta["job_kind"]:
- status = FolderStatus.IMPORTED
- elif "preview" in meta["job_kind"]:
- status = FolderStatus.PREVIEWED
- else:
- raise ValueError("Unknown job kind")
- else:
- status = FolderStatus.UNKNOWN
-
- # Additional check the return value of the job for
- # exception values
-
- # log.debug(
- # f"Job details:\n"
- # + f"{job.enqueued_at=}\n"
- # + f"{job.started_at=}\n"
- # + f"{job.created_at=}\n"
- # + f"{job.ended_at=}\n"
- # + f"{job.enqueue_at_front=}"
- # )
-
- # We normally catch failed jobs early on but just
- # in case we also check
- res = job.latest_result()
- if (
- res is not None
- and res.return_value is not None
- # HACK: SerializedException contains a type and message attribute
- and isinstance(res.return_value, dict)
- and "type" in res.return_value
- and "message" in res.return_value
- ):
- exc = SerializedException(
- type=res.return_value["type"],
- message=res.return_value["message"],
- description=res.return_value.get("description"),
- trace=res.return_value.get("trace"),
- )
- status = FolderStatus.FAILED
- else:
- exc = None
-
- return status, job_date, exc
-
-
-def _get_jobs(registry, connection):
- jobs = Job.fetch_many(registry.get_job_ids(), connection=connection)
- jobs = [j for j in jobs if j is not None]
-
- return jobs
-
-
-def _is_hash_in_jobs(
- hash: str, jobs: list[Job]
-) -> tuple[dict[str, str], Job, datetime] | None:
- for j in jobs:
- meta = j.get_meta(False)
- if meta.get("folder_hash") == hash:
- # jobs dont have an updated_at attribute.
- job_dates = [
- d
- for d in [
- j.enqueued_at, # at least this one should never be None.
- j.started_at,
- j.created_at,
- j.ended_at,
- ]
- if d is not None
- ]
-
- return meta, j, max(job_dates)
- return None
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+
+from quart import jsonify, request
+from rq.job import Job
+from sqlalchemy import select
+
+from beets_flask import invoker
+from beets_flask.database import db_session_factory
+from beets_flask.database.models.states import (
+ FolderInDb,
+ SessionStateInDb,
+ TaskStateInDb,
+)
+from beets_flask.importer.progress import FolderStatus, Progress
+from beets_flask.logger import log
+from beets_flask.server.exceptions import (
+ InvalidUsageException,
+ NotFoundException,
+ SerializedException,
+)
+from beets_flask.server.utility import (
+ pop_extra_meta,
+ pop_folder_params,
+ pop_query_param,
+)
+from beets_flask.server.websocket.status import FolderStatusUpdate, JobStatusUpdate
+
+from .base import ModelAPIBlueprint
+
+__all__ = ["SessionAPIBlueprint"]
+
+
+class SessionAPIBlueprint(ModelAPIBlueprint[SessionStateInDb]):
+ def __init__(self):
+ super().__init__(SessionStateInDb, url_prefix="/session")
+
+ def _register_routes(self) -> None:
+ """Register the routes for the blueprint."""
+ super()._register_routes()
+ self.blueprint.route("/by_folder", methods=["POST"])(self.get_by_folder)
+ self.blueprint.route("/status", methods=["GET"])(self.get_status)
+ self.blueprint.route("/enqueue", methods=["POST"])(self.enqueue)
+ self.blueprint.route("/add_candidates", methods=["POST"])(self.add_candidates)
+
+ async def get_by_folder(self):
+ """Returns the most recent session state for a given folder hash or path."""
+
+ params = await request.get_json()
+ folder_hashes, folder_paths = pop_folder_params(params, allow_mismatch=True)
+
+ if len(folder_hashes) != 1 and len(folder_paths) != 1:
+ raise InvalidUsageException(
+ "Provide one folder hash OR one folder path", status_code=400
+ )
+
+ with db_session_factory() as db_session:
+ item = self.model.get_by_hash_and_path(
+ hash=folder_hashes[0],
+ path=folder_paths[0],
+ db_session=db_session,
+ )
+
+ if not item:
+ # TODO: by path, validation of session hash
+ # raise, but we do not want to spam the
+ # frontend console with errors.
+ # we manually handle this in sessionQueryOptions.
+ raise NotFoundException(
+ f"Item with {folder_hashes=} {folder_paths=} not found",
+ status_code=200,
+ )
+
+ return jsonify(item.to_dict())
+
+ async def enqueue(self):
+ """Start a new session for a given folder hash or enqueue a new job for an existing session.
+
+ You need to specify the folder of the album,
+ and it has to be a valid album folder.
+
+ # Params
+ - `kind` (str): The kind of the tag. See `invoker.EnqueueKind`.
+
+ """
+ params = await request.get_json()
+ folder_hashes, folder_paths = pop_folder_params(params)
+ kind = pop_query_param(params, "kind", str)
+ if not isinstance(kind, str):
+ raise InvalidUsageException(
+ "kind must be one of " + str(invoker.EnqueueKind.__members__)
+ )
+
+ extra_meta = pop_extra_meta(params, n_jobs=len(folder_hashes))
+
+ jobs: list[Job] = []
+
+ for hash, path, meta in zip(folder_hashes, folder_paths, extra_meta):
+ jobs.append(
+ await invoker.enqueue(
+ hash,
+ str(path),
+ invoker.EnqueueKind.from_str(kind),
+ extra_meta=meta,
+ **params,
+ )
+ )
+
+ return jsonify(
+ JobStatusUpdate(
+ message=f"{len(jobs)} added as kind: {kind}",
+ num_jobs=len(jobs),
+ job_metas=[j.get_meta() for j in jobs], # type: ignore
+ )
+ )
+
+ async def add_candidates(self):
+ """Search for new candidates.
+
+ Helper function which is pretty similar to enqueue. But only allows for a single
+ folder hash and path.
+ """
+ params = await request.get_json()
+ task_id = pop_query_param(params, "task_id", str)
+ session_id = pop_query_param(params, "session_id", str)
+
+ folder_hash: str | None = None
+ folder_path: str | None = None
+ with db_session_factory() as db_session:
+ # Get path, hash by task_id
+ if session_id is not None:
+ stmt_session = select(SessionStateInDb).where(
+ SessionStateInDb.id == session_id
+ )
+ session_indb = db_session.execute(stmt_session).scalar_one_or_none()
+ if session_indb is None:
+ raise InvalidUsageException(
+ f"Session with session_id {session_id} not found",
+ )
+
+ folder_path = session_indb.folder.full_path
+ folder_hash = session_indb.folder.hash
+
+ if task_id is not None:
+ stmt_task = select(TaskStateInDb).where(TaskStateInDb.id == task_id)
+ task_indb = db_session.execute(stmt_task).scalar_one_or_none()
+ if task_indb is None:
+ raise InvalidUsageException(
+ f"Task with task_id {task_id} not found",
+ )
+
+ folder_path = task_indb.session.folder.full_path
+ folder_hash = task_indb.session.folder.hash
+
+ if folder_hash is None or folder_path is None:
+ raise InvalidUsageException(
+ "task_id or session_id must be provided",
+ )
+
+ extra_meta = pop_extra_meta(params, n_jobs=1)
+
+ job = await invoker.enqueue(
+ folder_hash,
+ folder_path,
+ invoker.EnqueueKind.PREVIEW_ADD_CANDIDATES,
+ extra_meta=extra_meta[0],
+ **params,
+ )
+
+ return jsonify(
+ JobStatusUpdate(
+ message=f"searching_candidates for {folder_path} folders",
+ num_jobs=1,
+ job_metas=[job.get_meta()], # type: ignore
+ )
+ )
+
+ async def get_status(self):
+ """Get all pending tasks."""
+
+ params = await request.get_json()
+ folder_hashes, folder_paths = pop_folder_params(params)
+
+ stats: list[FolderStatusUpdate] = []
+
+ if len(folder_hashes) == 0:
+ stmt = select(FolderInDb).order_by(FolderInDb.created_at.desc())
+ with db_session_factory() as session:
+ folders = session.execute(stmt).scalars().all()
+ folder_hashes = [f.hash for f in folders]
+ folder_paths = [f.full_path for f in folders]
+
+ log.debug(f"Checking status for {len(folder_hashes)} folders")
+
+ for hash, path in zip(folder_hashes, folder_paths):
+ log.debug(f"Checking folder status via session from db: {path} ({hash})")
+ db_status, db_date, db_exc = _get_folder_status_from_db(hash)
+ log.debug(f"Found {db_status=} {db_date=} {db_exc=}")
+
+ log.debug(f"Checking folder status via job queues: {path} ({hash})")
+ job_status, job_date, job_exc = _get_folder_status_from_queues(hash)
+ log.debug(f"Found {job_status=} {job_date=} {job_exc=}")
+
+ # just for None casting, timezones prevent comparing
+ if db_date is not None:
+ db_date = db_date.replace(tzinfo=None)
+ if job_date is not None:
+ job_date = job_date.replace(tzinfo=None)
+
+ status = FolderStatus.UNKNOWN
+ exc = None
+ if db_date is None and job_date is None:
+ pass
+ elif (db_date or datetime.min) + timedelta(seconds=1) >= (
+ job_date or datetime.min
+ ):
+ # Sometimes, the job_date might be some .7secs after db_date and would
+ # get favoured, so we added a second of leeway.
+ log.debug(f"Using status from DB: {db_date} >= {job_date}")
+ status = db_status
+ exc = db_exc
+ else:
+ log.debug(f"Using status from job queue : {db_date} < {job_date}")
+ status = job_status
+ exc = job_exc
+
+ stats.append(
+ FolderStatusUpdate(path=str(path), hash=hash, status=status, exc=exc)
+ )
+
+ return jsonify(stats)
+
+
+def _get_folder_status_from_db(
+ hash: str,
+) -> tuple[FolderStatus, datetime | None, SerializedException | None]:
+ with db_session_factory() as db_session:
+ stmt_s = (
+ select(SessionStateInDb)
+ .where(SessionStateInDb.folder_hash == hash)
+ .order_by(SessionStateInDb.folder_revision.desc())
+ )
+ s_state_indb = db_session.execute(stmt_s).scalars().first()
+ if s_state_indb is None:
+ return FolderStatus.UNKNOWN, None, None
+ else:
+ # PS: This progress <-> state mapping feels inconsistent.
+ # There should be a better place for this.
+ status = FolderStatus.UNKNOWN
+ if s_state_indb.progress == Progress.NOT_STARTED:
+ status = FolderStatus.NOT_STARTED
+ elif s_state_indb.progress == Progress.DELETING:
+ status = FolderStatus.DELETING
+ elif s_state_indb.progress == Progress.DELETION_COMPLETED:
+ status = FolderStatus.DELETED
+ elif s_state_indb.progress == Progress.PREVIEW_COMPLETED:
+ status = FolderStatus.PREVIEWED
+ elif s_state_indb.progress == Progress.IMPORT_COMPLETED:
+ status = FolderStatus.IMPORTED
+ elif s_state_indb.progress < Progress.PREVIEW_COMPLETED:
+ status = FolderStatus.PREVIEWING
+ elif s_state_indb.progress < Progress.IMPORT_COMPLETED:
+ status = FolderStatus.IMPORTING
+
+ if s_state_indb.exception is not None:
+ exc = s_state_indb.exception
+ status = FolderStatus.FAILED
+ else:
+ exc = None
+
+ return status, s_state_indb.updated_at, exc
+
+
+def _get_folder_status_from_queues(
+ hash: str,
+) -> tuple[FolderStatus, datetime | None, SerializedException | None]:
+ from beets_flask.redis import queues, redis_conn
+
+ # could not simply import queues from beets_flask.redis ?
+ # queues = [import_queue, preview_queue]
+
+ # hold a list of jobs, sorted by the queue/job status
+ q_kinds: dict[str, list[Job]] = {
+ "queued": [],
+ "scheduled": [],
+ "started": [],
+ "failed": [],
+ "finished": [],
+ }
+
+ for q in queues:
+ q_kinds["queued"].extend(_get_jobs(q, connection=redis_conn))
+ q_kinds["scheduled"].extend(
+ _get_jobs(q.scheduled_job_registry, connection=redis_conn)
+ )
+ q_kinds["started"].extend(
+ _get_jobs(q.started_job_registry, connection=redis_conn)
+ )
+ q_kinds["failed"].extend(
+ _get_jobs(q.failed_job_registry, connection=redis_conn)
+ )
+ q_kinds["finished"].extend(
+ _get_jobs(q.finished_job_registry, connection=redis_conn)
+ )
+
+ # We always want the latest info, no matter from which queue.
+ job_date = None
+ status = FolderStatus.UNKNOWN
+ exc = None
+
+ for kind in q_kinds.keys():
+ jobs = q_kinds[kind]
+
+ meta_job_date = _is_hash_in_jobs(hash, jobs)
+ if meta_job_date is None:
+ # Hash not found
+ continue
+
+ meta, job, _job_date = meta_job_date
+ if job_date is None or _job_date > job_date:
+ job_date = _job_date
+ else:
+ # Job is not newer than from other queue
+ continue
+
+ if kind in ["queued", "scheduled"]:
+ status = FolderStatus.PENDING
+ elif kind == "failed":
+ status = FolderStatus.FAILED
+ elif kind == "started":
+ if "import" in meta["job_kind"]:
+ status = FolderStatus.IMPORTING
+ elif "preview" in meta["job_kind"]:
+ status = FolderStatus.PREVIEWING
+ else:
+ raise ValueError("Unknown job kind")
+ elif kind == "finished":
+ if "import" in meta["job_kind"]:
+ status = FolderStatus.IMPORTED
+ elif "preview" in meta["job_kind"]:
+ status = FolderStatus.PREVIEWED
+ else:
+ raise ValueError("Unknown job kind")
+ else:
+ status = FolderStatus.UNKNOWN
+
+ # Additional check the return value of the job for
+ # exception values
+
+ # log.debug(
+ # f"Job details:\n"
+ # + f"{job.enqueued_at=}\n"
+ # + f"{job.started_at=}\n"
+ # + f"{job.created_at=}\n"
+ # + f"{job.ended_at=}\n"
+ # + f"{job.enqueue_at_front=}"
+ # )
+
+ # We normally catch failed jobs early on but just
+ # in case we also check
+ res = job.latest_result()
+ if (
+ res is not None
+ and res.return_value is not None
+ # HACK: SerializedException contains a type and message attribute
+ and isinstance(res.return_value, dict)
+ and "type" in res.return_value
+ and "message" in res.return_value
+ ):
+ exc = SerializedException(
+ type=res.return_value["type"],
+ message=res.return_value["message"],
+ description=res.return_value.get("description"),
+ trace=res.return_value.get("trace"),
+ )
+ status = FolderStatus.FAILED
+ else:
+ exc = None
+
+ return status, job_date, exc
+
+
+def _get_jobs(registry, connection):
+ jobs = Job.fetch_many(registry.get_job_ids(), connection=connection)
+ jobs = [j for j in jobs if j is not None]
+
+ return jobs
+
+
+def _is_hash_in_jobs(
+ hash: str, jobs: list[Job]
+) -> tuple[dict[str, str], Job, datetime] | None:
+ for j in jobs:
+ meta = j.get_meta(False)
+ if meta.get("folder_hash") == hash:
+ # jobs dont have an updated_at attribute.
+ job_dates = [
+ d
+ for d in [
+ j.enqueued_at, # at least this one should never be None.
+ j.started_at,
+ j.created_at,
+ j.ended_at,
+ ]
+ if d is not None
+ ]
+
+ return meta, j, max(job_dates)
+ return None
diff --git a/backend/beets_flask/server/routes/exception.py b/backend/beets_flask/server/routes/exception.py
index 634ba297..66e2650a 100644
--- a/backend/beets_flask/server/routes/exception.py
+++ b/backend/beets_flask/server/routes/exception.py
@@ -1,154 +1,154 @@
-"""Exceptions and error handling for the Quart application.
-
-This module contains the error handling logic for the Quart application.
-It provides a way to handle errors in a consistent way and return them
-serialized to the frontend to handle gracefully.
-"""
-
-import traceback
-
-from confuse import ConfigError
-from quart import Blueprint, jsonify
-from werkzeug.exceptions import HTTPException
-
-from beets_flask import log
-from beets_flask.server.exceptions import (
- ApiException,
- IntegrityException,
- InvalidUsageException,
- NotFoundException,
- SerializedException,
-)
-
-error_bp = Blueprint("error", __name__)
-
-
-@error_bp.app_errorhandler(NotImplementedError)
-async def handle_not_implemented(error):
- return (
- SerializedException(
- type=type(error).__name__,
- message=str(error),
- description="This feature is not implemented yet",
- ),
- 501,
- )
-
-
-@error_bp.app_errorhandler(ApiException)
-async def handle_api_exception(exc: ApiException):
- """Api exceptions can set their own status code.
-
- see ../exception.py for more details.
- """
- return (
- SerializedException(
- type=type(exc).__name__,
- message=str(exc),
- description=exc.__doc__,
- ),
- exc.status_code,
- )
-
-
-@error_bp.app_errorhandler(ConfigError)
-async def handle_config_exception(error: ConfigError):
- log.error(f"Configuration Error: {error}")
- return (
- SerializedException(
- type=type(error).__name__,
- message=str(error),
- description=error.__doc__,
- ),
- 400,
- )
-
-
-@error_bp.app_errorhandler(FileNotFoundError)
-async def handle_file_not_found(error: FileNotFoundError):
- return (
- jsonify(
- SerializedException(
- type=type(error).__name__,
- message=str(error),
- description="This file was not found",
- )
- ),
- 404,
- )
-
-
-@error_bp.app_errorhandler(HTTPException)
-async def handle_exception(exc: HTTPException):
- """Return JSON instead of HTML for werkzeug HTTP errors."""
- # start with the correct headers and status code from the error
- return (
- SerializedException(
- type=type(exc).__name__,
- message=str(exc),
- description=exc.description,
- ),
- exc.code or 500,
- )
-
-
-@error_bp.app_errorhandler(Exception)
-async def handle_generic_error(exc: Exception):
- log.exception(f"Unhandled exception: {exc}")
- return (
- SerializedException(
- type=type(exc).__name__,
- message=str(exc),
- description="An unhandled exception occurred",
- trace="".join(traceback.format_tb(exc.__traceback__)),
- ),
- 500,
- )
-
-
-# ---------------------------------------------------------------------------- #
-# Test the error handling endpoints #
-# ---------------------------------------------------------------------------- #
-# see test/test_routes/test_exceptions.py for more details
-
-
-# Api exceptions
-@error_bp.route("/error/api", methods=["GET"])
-async def error():
- raise ApiException("This is a bad request")
-
-
-@error_bp.route("/error/invalidUsage", methods=["GET"])
-async def invalid_usage():
- raise InvalidUsageException("This is a bad request")
-
-
-@error_bp.route("/error/notFound", methods=["GET"])
-async def not_found():
- raise NotFoundException("This is a not found error")
-
-
-@error_bp.route("/error/integrity", methods=["GET"])
-async def integrity():
- raise IntegrityException("This is an integrity error")
-
-
-# Generic exceptions
-@error_bp.route("/error/notImplemented", methods=["GET"])
-async def not_implemented():
- raise NotImplementedError("This is not implemented")
-
-
-@error_bp.route("/error/configError", methods=["GET"])
-async def config_error():
- raise ConfigError("This is a config error")
-
-
-@error_bp.route("/error/fileNotFound", methods=["GET"])
-async def file_not_found():
- raise FileNotFoundError("This is a file not found error")
-
-
-@error_bp.route("/error/genericError", methods=["GET"])
-async def generic_error():
- raise Exception("An unhandled exception occurred")
+"""Exceptions and error handling for the Quart application.
+
+This module contains the error handling logic for the Quart application.
+It provides a way to handle errors in a consistent way and return them
+serialized to the frontend to handle gracefully.
+"""
+
+import traceback
+
+from confuse import ConfigError
+from quart import Blueprint, jsonify
+from werkzeug.exceptions import HTTPException
+
+from beets_flask import log
+from beets_flask.server.exceptions import (
+ ApiException,
+ IntegrityException,
+ InvalidUsageException,
+ NotFoundException,
+ SerializedException,
+)
+
+error_bp = Blueprint("error", __name__)
+
+
+@error_bp.app_errorhandler(NotImplementedError)
+async def handle_not_implemented(error):
+ return (
+ SerializedException(
+ type=type(error).__name__,
+ message=str(error),
+ description="This feature is not implemented yet",
+ ),
+ 501,
+ )
+
+
+@error_bp.app_errorhandler(ApiException)
+async def handle_api_exception(exc: ApiException):
+ """Api exceptions can set their own status code.
+
+ see ../exception.py for more details.
+ """
+ return (
+ SerializedException(
+ type=type(exc).__name__,
+ message=str(exc),
+ description=exc.__doc__,
+ ),
+ exc.status_code,
+ )
+
+
+@error_bp.app_errorhandler(ConfigError)
+async def handle_config_exception(error: ConfigError):
+ log.error(f"Configuration Error: {error}")
+ return (
+ SerializedException(
+ type=type(error).__name__,
+ message=str(error),
+ description=error.__doc__,
+ ),
+ 400,
+ )
+
+
+@error_bp.app_errorhandler(FileNotFoundError)
+async def handle_file_not_found(error: FileNotFoundError):
+ return (
+ jsonify(
+ SerializedException(
+ type=type(error).__name__,
+ message=str(error),
+ description="This file was not found",
+ )
+ ),
+ 404,
+ )
+
+
+@error_bp.app_errorhandler(HTTPException)
+async def handle_exception(exc: HTTPException):
+ """Return JSON instead of HTML for werkzeug HTTP errors."""
+ # start with the correct headers and status code from the error
+ return (
+ SerializedException(
+ type=type(exc).__name__,
+ message=str(exc),
+ description=exc.description,
+ ),
+ exc.code or 500,
+ )
+
+
+@error_bp.app_errorhandler(Exception)
+async def handle_generic_error(exc: Exception):
+ log.exception(f"Unhandled exception: {exc}")
+ return (
+ SerializedException(
+ type=type(exc).__name__,
+ message=str(exc),
+ description="An unhandled exception occurred",
+ trace="".join(traceback.format_tb(exc.__traceback__)),
+ ),
+ 500,
+ )
+
+
+# ---------------------------------------------------------------------------- #
+# Test the error handling endpoints #
+# ---------------------------------------------------------------------------- #
+# see test/test_routes/test_exceptions.py for more details
+
+
+# Api exceptions
+@error_bp.route("/error/api", methods=["GET"])
+async def error():
+ raise ApiException("This is a bad request")
+
+
+@error_bp.route("/error/invalidUsage", methods=["GET"])
+async def invalid_usage():
+ raise InvalidUsageException("This is a bad request")
+
+
+@error_bp.route("/error/notFound", methods=["GET"])
+async def not_found():
+ raise NotFoundException("This is a not found error")
+
+
+@error_bp.route("/error/integrity", methods=["GET"])
+async def integrity():
+ raise IntegrityException("This is an integrity error")
+
+
+# Generic exceptions
+@error_bp.route("/error/notImplemented", methods=["GET"])
+async def not_implemented():
+ raise NotImplementedError("This is not implemented")
+
+
+@error_bp.route("/error/configError", methods=["GET"])
+async def config_error():
+ raise ConfigError("This is a config error")
+
+
+@error_bp.route("/error/fileNotFound", methods=["GET"])
+async def file_not_found():
+ raise FileNotFoundError("This is a file not found error")
+
+
+@error_bp.route("/error/genericError", methods=["GET"])
+async def generic_error():
+ raise Exception("An unhandled exception occurred")
diff --git a/backend/beets_flask/server/routes/frontend.py b/backend/beets_flask/server/routes/frontend.py
index 747f9932..8a4e7d0f 100644
--- a/backend/beets_flask/server/routes/frontend.py
+++ b/backend/beets_flask/server/routes/frontend.py
@@ -1,32 +1,32 @@
-"""The glue between the compiled vite frontend and our backend."""
-
-from quart import Blueprint, current_app, send_from_directory
-
-frontend_bp = Blueprint("frontend", __name__)
-
-
-# Register frontend folder
-# basically a reverse proxy for the frontend
-@frontend_bp.route("/", defaults={"path": "index.html"})
-@frontend_bp.route("/")
-async def reverse_proxy(path):
- """Link to vite resources."""
- # not include assets
- if (
- not "assets" in path
- and not "logo_beets.png" in path
- and not "logo_flask.png" in path
- and not path.startswith("favicon.ico")
- ):
- path = "index.html"
-
- # Remove everything infront of assets
- if "assets" in path:
- path = path[path.index("assets") :]
- if "logo_beets.png" in path:
- path = path[path.index("logo_beets.png") :]
- if "logo_flask.png" in path:
- path = path[path.index("logo_flask.png") :]
-
- r = await send_from_directory(current_app.config["FRONTEND_DIST_DIR"], path)
- return r
+"""The glue between the compiled vite frontend and our backend."""
+
+from quart import Blueprint, current_app, send_from_directory
+
+frontend_bp = Blueprint("frontend", __name__)
+
+
+# Register frontend folder
+# basically a reverse proxy for the frontend
+@frontend_bp.route("/", defaults={"path": "index.html"})
+@frontend_bp.route("/")
+async def reverse_proxy(path):
+ """Link to vite resources."""
+ # not include assets
+ if (
+ not "assets" in path
+ and not "logo_beets.png" in path
+ and not "logo_flask.png" in path
+ and not path.startswith("favicon.ico")
+ ):
+ path = "index.html"
+
+ # Remove everything infront of assets
+ if "assets" in path:
+ path = path[path.index("assets") :]
+ if "logo_beets.png" in path:
+ path = path[path.index("logo_beets.png") :]
+ if "logo_flask.png" in path:
+ path = path[path.index("logo_flask.png") :]
+
+ r = await send_from_directory(current_app.config["FRONTEND_DIST_DIR"], path)
+ return r
diff --git a/backend/beets_flask/server/routes/inbox.py b/backend/beets_flask/server/routes/inbox.py
index da3539a3..c2960695 100644
--- a/backend/beets_flask/server/routes/inbox.py
+++ b/backend/beets_flask/server/routes/inbox.py
@@ -1,294 +1,307 @@
-import os
-import shutil
-from datetime import datetime
-from pathlib import Path
-from typing import TypedDict
-
-from cachetools import Cache
-from quart import Blueprint, jsonify, request
-from sqlalchemy import func, select
-
-from beets_flask.database import db_session_factory
-from beets_flask.database.models.states import FolderInDb, SessionStateInDb
-from beets_flask.disk import (
- Archive,
- Folder,
- dir_files,
- dir_size,
- fs_item_from_path,
- path_to_folder,
-)
-from beets_flask.importer.progress import Progress
-from beets_flask.logger import log
-from beets_flask.server.exceptions import InvalidUsageException, NotFoundException
-from beets_flask.server.utility import (
- pop_folder_params,
-)
-from beets_flask.server.websocket.status import (
- trigger_clear_cache,
-)
-from beets_flask.watchdog.inbox import (
- get_inbox_folders,
- get_inbox_for_path,
-)
-
-inbox_bp = Blueprint("inbox", __name__, url_prefix="/inbox")
-
-
-@inbox_bp.route("/tree", methods=["GET"])
-async def get_tree():
- """Get all paths inside the inbox folder(s)."""
-
- inbox_folders = get_inbox_folders()
-
- # Create dict representation of inbox folders
- folders: list[Folder] = []
- for folder in inbox_folders:
- folders.append(path_to_folder(folder, subdirs=False))
-
- return jsonify(folders)
-
-
-@inbox_bp.route("/folder", methods=["POST"])
-async def get_folder():
- """Get the folder structure for a given inbox folder.
-
- Parameters
- ----------
- folder_path : str
- The path to the folder to get the structure for.
- """
- params = await request.get_json()
-
- folder_hashes, folder_paths = pop_folder_params(params, allow_mismatch=True)
-
- if len(folder_paths) != 1 and len(folder_hashes) != 1:
- raise InvalidUsageException(
- f"Only one folder path or hash must be provided. Got: {folder_hashes=}, {folder_paths=}"
- )
-
- folder_path = folder_paths[0] if len(folder_paths) == 1 else None
- folder_hash = folder_hashes[0] if len(folder_hashes) == 1 else None
-
- # Only absolute paths are allowed
- if folder_path is not None and not Path(folder_path).is_absolute():
- raise InvalidUsageException(
- f"Only absolute paths are allowed. Got: {folder_path=}"
- )
-
- folder: Folder | Archive | None = None
-
- # If a hash is provided, try to get the folder from the inbox cache first
- # If this fails, try to get from db
- if folder_hash is not None:
- inbox_folders = get_inbox_folders()
- for inbox_folder in inbox_folders:
- for f in path_to_folder(inbox_folder, subdirs=False).walk():
- if isinstance(f, (Folder, Archive)) and f.hash == folder_hash:
- folder = f
- break
-
- if folder is not None:
- break
-
- if folder is None:
- with db_session_factory() as session:
- stmt = select(FolderInDb).where(FolderInDb.id == folder_hash)
- f_in_db = session.execute(stmt).scalars().first()
- if f_in_db is not None:
- folder = f_in_db.to_live_folder()
-
- # If a path is provided, and we did not find the folder via hash,
- # try to create folder or get it from db
- if folder is None and folder_path is not None:
- try:
- folder_path = Path(folder_path).resolve()
- # If the path is absolute, we can create the folder directly
- _folder = fs_item_from_path(folder_path, subdirs=False)
- assert isinstance(_folder, (Folder, Archive)), (
- "Path must be a folder or archive"
- )
- folder = _folder
- except FileNotFoundError:
- # Try to lookup in db, maybe folder doesn't exist anymore?
- with db_session_factory() as session:
- stmt = (
- select(FolderInDb)
- .where(FolderInDb.full_path == str(folder_path))
- .order_by(FolderInDb.updated_at.desc())
- )
-
- f_in_db = session.execute(stmt).scalars().first()
- if f_in_db is not None:
- folder = f_in_db.to_live_folder()
-
- # If we still don't have a folder, raise an error
- if folder is None:
- raise InvalidUsageException(
- f"Could not find folder with {folder_hash=} or path {folder_path=}.",
- status_code=404,
- )
-
- return jsonify(folder)
-
-
-@inbox_bp.route("/tree/refresh", methods=["POST"])
-async def refresh_cache():
- """Clear the cache for the path_to_dict function."""
- await trigger_clear_cache()
- return "Ok"
-
-
-@inbox_bp.route("/delete", methods=["DELETE"])
-async def delete():
- """Remove all folders provided in the request body via folder_paths.
-
- Parameters
- ----------
- folder_paths : list[str]
- The paths to the folders to remove.
- folder_hashes : list[str]
- The hashes of the folders to remove.
- """
- params = await request.get_json()
- folder_hashes, folder_paths = pop_folder_params(params, allow_empty=False)
- log.debug(f"Deleting folders: {folder_paths=}, {folder_hashes=}")
-
- # Deduplicate based on both path and hash (order-preserving)
- seen: set[tuple[Path, str]] = set()
- folder_paths_and_hashes = []
- for path, hash in zip(folder_paths, folder_hashes):
- if (path, hash) not in seen:
- seen.add((path, hash))
- folder_paths_and_hashes.append((path, hash))
-
- # Sort by length of the path (longest first, to delete the most nested folders first)
- folder_paths_and_hashes = sorted(
- folder_paths_and_hashes, key=lambda x: len(x[0].parts), reverse=True
- )
-
- # Check that all hashes are (still) valid
- cache: Cache[str, bytes] = Cache(maxsize=2**16)
- folders: list[Folder | Archive] = []
- for folder_path, folder_hash in folder_paths_and_hashes:
- f = fs_item_from_path(folder_path, cache=cache)
- if not isinstance(f, (Folder, Archive)):
- log.debug(f"Skipping deletion of {folder_path}, not a folder or archive")
- continue
- folders.append(f)
- if f.hash != folder_hash:
- raise InvalidUsageException(
- "Folder hash does not match the current folder hash! Please refresh your hashes before deleting!",
- )
-
- # Delete the folders
- for f in folders:
- if isinstance(f, Archive):
- os.remove(f.full_path)
- elif isinstance(f, Folder):
- shutil.rmtree(f.full_path)
- else:
- raise InvalidUsageException(
- f"Cannot delete object of type {type(f)} at {f.full_path}"
- )
-
- # Clear the cache for the deleted folders
- await trigger_clear_cache()
-
- return jsonify(
- {
- "deleted": [f.full_path for f in folders],
- "hashes": [f.hash for f in folders],
- }
- )
-
-
-# ------------------------------------------------------------------------------------ #
-# Stats #
-# ------------------------------------------------------------------------------------ #
-
-
-class InboxStats(TypedDict):
- name: str
- path: str
-
- # Number of albums tagged via GUI
- tagged_via_gui: int
- # Number of albums imported via GUI
- imported_via_gui: int
-
- # Bytes of the inbox folder
- size: int
- nFiles: int
-
- last_created: datetime | None
-
-
-@inbox_bp.route("/stats", methods=["GET"])
-async def stats_for_all():
- """Get the stats for all inbox folders.
-
- Parameters
- ----------
- folder : str (optional)
- The folder to compute stats for. If not provided, all inbox folders are used.
- """
- folders = get_inbox_folders()
- stats = [compute_stats(f) for f in folders]
- return jsonify(stats)
-
-
-def compute_stats(folder: str):
- """Compute the stats for the inbox folder.
-
- # Path parameters
- folder: str (optional) - The folder to compute stats for
-
- """
- inbox = get_inbox_for_path(folder)
- if inbox is None:
- raise NotFoundException(f"Inbox folder `{folder} not found.")
-
- p = Path(folder)
-
- # Compute session stats
- with db_session_factory() as session:
- stmt = (
- select(func.count())
- .select_from(SessionStateInDb)
- .join(FolderInDb)
- .where(FolderInDb.full_path.like(f"{folder}%"))
- .where(SessionStateInDb.progress >= Progress.PREVIEW_COMPLETED)
- )
- n_tagged = session.execute(stmt).scalar_one()
-
- stmt = (
- select(func.count())
- .select_from(SessionStateInDb)
- .join(FolderInDb)
- .where(FolderInDb.full_path.like(f"{folder}%"))
- .where(SessionStateInDb.progress == Progress.IMPORT_COMPLETED)
- )
- n_imported = session.execute(stmt).scalar_one()
-
- # last created session
- stmt = (
- select(SessionStateInDb.created_at)
- .join(FolderInDb)
- .where(FolderInDb.full_path.like(f"{folder}%"))
- .order_by(SessionStateInDb.created_at.desc())
- .limit(1)
- )
- last_created = session.execute(stmt).scalars().first()
-
- ret_map: InboxStats = {
- "name": inbox["name"],
- "path": inbox["path"],
- "nFiles": dir_files(p),
- "size": dir_size(p),
- "tagged_via_gui": n_tagged,
- "imported_via_gui": n_imported,
- "last_created": last_created,
- }
-
- return ret_map
+import os
+import shutil
+from datetime import datetime
+from pathlib import Path
+from typing import TypedDict
+
+from cachetools import Cache
+from quart import Blueprint, jsonify, request
+from sqlalchemy import func, select
+
+from beets_flask.database import db_session_factory
+from beets_flask.database.models.states import FolderInDb, SessionStateInDb
+from beets_flask.disk import (
+ Archive,
+ Folder,
+ dir_files,
+ dir_size,
+ fs_item_from_path,
+ get_cached_dir_stats,
+ path_to_folder,
+)
+from beets_flask.importer.progress import Progress
+from beets_flask.logger import log
+from beets_flask.server.exceptions import InvalidUsageException, NotFoundException
+from beets_flask.server.utility import (
+ pop_folder_params,
+)
+from beets_flask.server.websocket.status import (
+ trigger_clear_cache,
+)
+from beets_flask.watchdog.inbox import (
+ get_inbox_folders,
+ get_inbox_for_path,
+)
+
+inbox_bp = Blueprint("inbox", __name__, url_prefix="/inbox")
+
+
+@inbox_bp.route("/tree", methods=["GET"])
+async def get_tree():
+ """Get all paths inside the inbox folder(s)."""
+
+ inbox_folders = get_inbox_folders()
+
+ # Create dict representation of inbox folders
+ folders: list[Folder] = []
+ for folder in inbox_folders:
+ folders.append(path_to_folder(folder, subdirs=False))
+
+ return jsonify(folders)
+
+
+@inbox_bp.route("/folder", methods=["POST"])
+async def get_folder():
+ """Get the folder structure for a given inbox folder.
+
+ Parameters
+ ----------
+ folder_path : str
+ The path to the folder to get the structure for.
+ """
+ params = await request.get_json()
+
+ folder_hashes, folder_paths = pop_folder_params(params, allow_mismatch=True)
+
+ if len(folder_paths) != 1 and len(folder_hashes) != 1:
+ raise InvalidUsageException(
+ f"Only one folder path or hash must be provided. Got: {folder_hashes=}, {folder_paths=}"
+ )
+
+ folder_path = folder_paths[0] if len(folder_paths) == 1 else None
+ folder_hash = folder_hashes[0] if len(folder_hashes) == 1 else None
+
+ # Only absolute paths are allowed
+ if folder_path is not None and not Path(folder_path).is_absolute():
+ raise InvalidUsageException(
+ f"Only absolute paths are allowed. Got: {folder_path=}"
+ )
+
+ folder: Folder | Archive | None = None
+
+ # If a hash is provided, try to get the folder from the inbox cache first
+ # If this fails, try to get from db
+ if folder_hash is not None:
+ inbox_folders = get_inbox_folders()
+ for inbox_folder in inbox_folders:
+ for f in path_to_folder(inbox_folder, subdirs=False).walk():
+ if isinstance(f, (Folder, Archive)) and f.hash == folder_hash:
+ folder = f
+ break
+
+ if folder is not None:
+ break
+
+ if folder is None:
+ with db_session_factory() as session:
+ stmt = select(FolderInDb).where(FolderInDb.id == folder_hash)
+ f_in_db = session.execute(stmt).scalars().first()
+ if f_in_db is not None:
+ folder = f_in_db.to_live_folder()
+
+ # If a path is provided, and we did not find the folder via hash,
+ # try to create folder or get it from db
+ if folder is None and folder_path is not None:
+ try:
+ folder_path = Path(folder_path).resolve()
+ # If the path is absolute, we can create the folder directly
+ _folder = fs_item_from_path(folder_path, subdirs=False)
+ assert isinstance(_folder, (Folder, Archive)), (
+ "Path must be a folder or archive"
+ )
+ folder = _folder
+ except FileNotFoundError:
+ # Try to lookup in db, maybe folder doesn't exist anymore?
+ with db_session_factory() as session:
+ stmt = (
+ select(FolderInDb)
+ .where(FolderInDb.full_path == str(folder_path))
+ .order_by(FolderInDb.updated_at.desc())
+ )
+
+ f_in_db = session.execute(stmt).scalars().first()
+ if f_in_db is not None:
+ folder = f_in_db.to_live_folder()
+
+ # If we still don't have a folder, raise an error
+ if folder is None:
+ raise InvalidUsageException(
+ f"Could not find folder with {folder_hash=} or path {folder_path=}.",
+ status_code=404,
+ )
+
+ return jsonify(folder)
+
+
+@inbox_bp.route("/tree/refresh", methods=["POST"])
+async def refresh_cache():
+ """Clear the cache for the path_to_dict function."""
+ await trigger_clear_cache()
+ return "Ok"
+
+
+@inbox_bp.route("/delete", methods=["DELETE"])
+async def delete():
+ """Remove all folders provided in the request body via folder_paths.
+
+ Parameters
+ ----------
+ folder_paths : list[str]
+ The paths to the folders to remove.
+ folder_hashes : list[str]
+ The hashes of the folders to remove.
+ """
+ params = await request.get_json()
+ folder_hashes, folder_paths = pop_folder_params(params, allow_empty=False)
+ log.debug(f"Deleting folders: {folder_paths=}, {folder_hashes=}")
+
+ # Deduplicate based on both path and hash (order-preserving)
+ seen: set[tuple[Path, str]] = set()
+ folder_paths_and_hashes = []
+ for path, hash in zip(folder_paths, folder_hashes):
+ if (path, hash) not in seen:
+ seen.add((path, hash))
+ folder_paths_and_hashes.append((path, hash))
+
+ # Sort by length of the path (longest first, to delete the most nested folders first)
+ folder_paths_and_hashes = sorted(
+ folder_paths_and_hashes, key=lambda x: len(x[0].parts), reverse=True
+ )
+
+ # Check that all hashes are (still) valid
+ cache: Cache[str, bytes] = Cache(maxsize=2**16)
+ folders: list[Folder | Archive] = []
+ for folder_path, folder_hash in folder_paths_and_hashes:
+ f = fs_item_from_path(folder_path, cache=cache)
+ if not isinstance(f, (Folder, Archive)):
+ log.debug(f"Skipping deletion of {folder_path}, not a folder or archive")
+ continue
+ folders.append(f)
+ if f.hash != folder_hash:
+ raise InvalidUsageException(
+ "Folder hash does not match the current folder hash! Please refresh your hashes before deleting!",
+ )
+
+ # Delete the folders
+ for f in folders:
+ if isinstance(f, Archive):
+ os.remove(f.full_path)
+ elif isinstance(f, Folder):
+ shutil.rmtree(f.full_path)
+ else:
+ raise InvalidUsageException(
+ f"Cannot delete object of type {type(f)} at {f.full_path}"
+ )
+
+ # Clear the cache for the deleted folders
+ await trigger_clear_cache()
+
+ return jsonify(
+ {
+ "deleted": [f.full_path for f in folders],
+ "hashes": [f.hash for f in folders],
+ }
+ )
+
+
+# ------------------------------------------------------------------------------------ #
+# Stats #
+# ------------------------------------------------------------------------------------ #
+
+
+class InboxStats(TypedDict):
+ name: str
+ path: str
+
+ # Number of albums tagged via GUI
+ tagged_via_gui: int
+ # Number of albums imported via GUI
+ imported_via_gui: int
+
+ # Bytes of the inbox folder
+ size: int
+ nFiles: int
+
+ last_created: datetime | None
+
+
+@inbox_bp.route("/stats", methods=["GET"])
+async def stats_for_all():
+ """Get the stats for all inbox folders.
+
+ Parameters
+ ----------
+ folder : str (optional)
+ The folder to compute stats for. If not provided, all inbox folders are used.
+ """
+ folders = get_inbox_folders()
+ stats = [compute_stats(f) for f in folders]
+ return jsonify(stats)
+
+
+def compute_stats(folder: str):
+ """Compute the stats for the inbox folder.
+
+ # Path parameters
+ folder: str (optional) - The folder to compute stats for
+
+ """
+ inbox = get_inbox_for_path(folder)
+ if inbox is None:
+ raise NotFoundException(f"Inbox folder `{folder} not found.")
+
+ p = Path(folder)
+
+ # Compute session stats
+ with db_session_factory() as session:
+ stmt = (
+ select(func.count())
+ .select_from(SessionStateInDb)
+ .join(FolderInDb)
+ .where(FolderInDb.full_path.like(f"{folder}%"))
+ .where(SessionStateInDb.progress >= Progress.PREVIEW_COMPLETED)
+ )
+ n_tagged = session.execute(stmt).scalar_one()
+
+ stmt = (
+ select(func.count())
+ .select_from(SessionStateInDb)
+ .join(FolderInDb)
+ .where(FolderInDb.full_path.like(f"{folder}%"))
+ .where(SessionStateInDb.progress == Progress.IMPORT_COMPLETED)
+ )
+ n_imported = session.execute(stmt).scalar_one()
+
+ # last created session
+ stmt = (
+ select(SessionStateInDb.created_at)
+ .join(FolderInDb)
+ .where(FolderInDb.full_path.like(f"{folder}%"))
+ .order_by(SessionStateInDb.created_at.desc())
+ .limit(1)
+ )
+ last_created = session.execute(stmt).scalars().first()
+
+ cached = get_cached_dir_stats(p)
+ n_files = (
+ cached.n_files
+ if cached is not None and cached.n_files is not None
+ else dir_files(p)
+ )
+ size = (
+ cached.size_bytes
+ if cached is not None and cached.size_bytes is not None
+ else dir_size(p)
+ )
+
+ ret_map: InboxStats = {
+ "name": inbox["name"],
+ "path": inbox["path"],
+ "nFiles": n_files,
+ "size": size,
+ "tagged_via_gui": n_tagged,
+ "imported_via_gui": n_imported,
+ "last_created": last_created,
+ }
+
+ return ret_map
diff --git a/backend/beets_flask/server/routes/library/__init__.py b/backend/beets_flask/server/routes/library/__init__.py
index 01dc54ac..eca26084 100644
--- a/backend/beets_flask/server/routes/library/__init__.py
+++ b/backend/beets_flask/server/routes/library/__init__.py
@@ -1,51 +1,51 @@
-"""Some wrapper function around the beets library.
-
-Makes it possible to query and expose items to the frontend.
-"""
-
-from beets.ui import _open_library
-from quart import Blueprint, g
-
-from beets_flask.config import get_config
-
-from .artists import artists_bp
-from .artwork import artwork_pb
-from .audio import audio_bp
-from .metadata import metadata_bp
-from .resources import resource_bp
-from .stats import stats_bp
-
-library_bp = Blueprint("library", __name__, url_prefix="/library")
-library_bp.register_blueprint(artwork_pb)
-library_bp.register_blueprint(audio_bp)
-library_bp.register_blueprint(resource_bp)
-library_bp.register_blueprint(stats_bp)
-library_bp.register_blueprint(artists_bp)
-library_bp.register_blueprint(metadata_bp)
-
-from typing import TYPE_CHECKING
-
-if TYPE_CHECKING:
- from beets.library import Library
- from quart.ctx import _AppCtxGlobals
-
- class LibraryCtx(_AppCtxGlobals):
- lib: Library
-
- g = LibraryCtx()
-
-
-@library_bp.before_request
-async def attach_library():
- """Attach the library to the global object.
-
- This allows to reuse an open library for each request in the same thread.
- """
- config = get_config()
- # we will need to see if keeping the db open from each thread is what we want,
- # the importer may want to write.
- if not hasattr(g, "lib") or g.lib is None:
- g.lib = _open_library(config)
- else:
- if str(g.lib.path) != str(config.as_path()):
- g.lib = _open_library(config)
+"""Some wrapper function around the beets library.
+
+Makes it possible to query and expose items to the frontend.
+"""
+
+from beets.ui import _open_library
+from quart import Blueprint, g
+
+from beets_flask.config import get_config
+
+from .artists import artists_bp
+from .artwork import artwork_pb
+from .audio import audio_bp
+from .metadata import metadata_bp
+from .resources import resource_bp
+from .stats import stats_bp
+
+library_bp = Blueprint("library", __name__, url_prefix="/library")
+library_bp.register_blueprint(artwork_pb)
+library_bp.register_blueprint(audio_bp)
+library_bp.register_blueprint(resource_bp)
+library_bp.register_blueprint(stats_bp)
+library_bp.register_blueprint(artists_bp)
+library_bp.register_blueprint(metadata_bp)
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from beets.library import Library
+ from quart.ctx import _AppCtxGlobals
+
+ class LibraryCtx(_AppCtxGlobals):
+ lib: Library
+
+ g = LibraryCtx()
+
+
+@library_bp.before_request
+async def attach_library():
+ """Attach the library to the global object.
+
+ This allows to reuse an open library for each request in the same thread.
+ """
+ config = get_config()
+ # we will need to see if keeping the db open from each thread is what we want,
+ # the importer may want to write.
+ if not hasattr(g, "lib") or g.lib is None:
+ g.lib = _open_library(config)
+ else:
+ if str(g.lib.path) != str(config.as_path()):
+ g.lib = _open_library(config)
diff --git a/backend/beets_flask/server/routes/library/artists.py b/backend/beets_flask/server/routes/library/artists.py
index 39a4ae6e..7e864027 100644
--- a/backend/beets_flask/server/routes/library/artists.py
+++ b/backend/beets_flask/server/routes/library/artists.py
@@ -1,170 +1,170 @@
-"""Artists endpoint.
-
-Split artists by separators, and do some basic aggregation.
-"""
-
-import re
-from typing import TYPE_CHECKING
-
-import pandas as pd
-from quart import Blueprint, Response, g
-
-from beets_flask.config import get_config
-from beets_flask.server.exceptions import NotFoundException
-
-artists_bp = Blueprint("artists", __name__)
-
-if TYPE_CHECKING:
- # For type hinting the global g object
- from . import g
-
-# TODOs:
-# Currently artist_sort is completely ignored. Im not even sure what it is supposed to do.
-# Also artistids are not used, but they are in the database.
-
-# Note: I wanted to use polars first but it does not support alpine images yet, so we use pandas instead.
-
-
-ARTIST_SEPARATORS: list[str] = get_config()["gui"]["library"][
- "artist_separators"
-].as_str_seq()
-
-
-def _split_pattern(separators: list[str]) -> str:
- return "|".join(map(re.escape, separators))
-
-
-split_pattern_artists = _split_pattern(ARTIST_SEPARATORS)
-
-
-def get_artists_pandas(table: str, artist: str | None = None) -> pd.DataFrame:
- """Get all artists from the database using pandas.
-
- Returns
- -------
- DataFrame with columns ['artist', 'count', 'last_added']
- """
- if table == "items":
- query = """
- SELECT
- artist,
- added
- FROM
- items
- """
- elif table == "albums":
- query = """
- SELECT
- albumartist AS artist,
- added
-
- FROM
- albums
- """
- else:
- raise ValueError(f"Invalid table name: {table}. Must be 'items' or 'albums'.")
-
- # Split the artist string by the specified separators
- artists: list[str] | None
- if len(ARTIST_SEPARATORS) > 0 and artist is not None:
- artists = [a.strip() for a in re.split(split_pattern_artists, artist)]
- elif artist is not None:
- artists = [artist.strip()]
- else:
- artists = None
-
- if artists is not None:
- # If an artist is specified, filter the query
- for i, a in enumerate(artists):
- if i == 0:
- query += f" WHERE instr(artist, ?) > 0"
- else:
- query += f" AND instr(artist, ?) > 0"
-
- with g.lib.transaction() as tx:
- rows = tx.query(query, artists) if artists else tx.query(query)
-
- # Read from the database
- df = pd.DataFrame(rows, columns=["artist", "added"])
-
- # Split artist strings into lists and explode into separate rows
- if len(ARTIST_SEPARATORS) > 0:
- df["artist"] = df["artist"].str.split(split_pattern_artists)
- df = df.explode("artist")
-
- # Strip whitespace
- df["artist"] = df["artist"].str.strip()
- df["added"] = df["added"] * 1000
-
- # Group by artist and aggregate
- result = (
- df.groupby("artist")
- .agg(
- count=("artist", "size"),
- last_added=("added", "max"),
- first_added=("added", "min"),
- )
- .reset_index()
- )
-
- if artists is not None:
- # If an artist is specified, filter the result (respect the separator and resolve as or)
- result = result[
- result["artist"].str.contains(
- _split_pattern(artists), case=False, regex=True
- )
- ]
- # Overwrite if there are multiple artists (i.e. joined by a separator)
- if len(artists) > 1 and not result.empty:
- result["artist"] = artist
-
- return result
-
-
-@artists_bp.route("/artists/", methods=["GET"])
-@artists_bp.route("/artists", methods=["GET"], defaults={"artist_name": None})
-async def all_artists(artist_name: str | None = None):
- """Get all artists from the database.
-
- This endpoint retrieves all artists from the database, splits them by
- specified separators and aggregates the data to count the number of items.
- """
- artists_albums = (
- get_artists_pandas("albums", artist_name)
- .rename(
- columns={
- "count": "album_count",
- "last_added": "last_album_added",
- "first_added": "first_album_added",
- }
- )
- .set_index("artist")
- )
- artists_items = (
- get_artists_pandas("items", artist_name)
- .rename(
- columns={
- "count": "item_count",
- "last_added": "last_item_added",
- "first_added": "first_item_added",
- }
- )
- .set_index("artist")
- )
- # Join the two DataFrames on artist name and count the number of items and albums
- artists = artists_albums.join(
- artists_items,
- how="outer",
- ).reset_index()
-
- # Fill n_albums and n_items with 0 if they are NaN
- artists["album_count"] = artists["album_count"].fillna(0).astype(int)
- artists["item_count"] = artists["item_count"].fillna(0).astype(int)
-
- if artist_name is not None:
- if artists.empty:
- raise NotFoundException(f"Artist '{artist_name}' not found.")
- else:
- return Response(artists.iloc[0].to_json(), mimetype="application/json")
-
- return Response(artists.to_json(orient="records"), mimetype="application/json")
+"""Artists endpoint.
+
+Split artists by separators, and do some basic aggregation.
+"""
+
+import re
+from typing import TYPE_CHECKING
+
+import pandas as pd
+from quart import Blueprint, Response, g
+
+from beets_flask.config import get_config
+from beets_flask.server.exceptions import NotFoundException
+
+artists_bp = Blueprint("artists", __name__)
+
+if TYPE_CHECKING:
+ # For type hinting the global g object
+ from . import g
+
+# TODOs:
+# Currently artist_sort is completely ignored. Im not even sure what it is supposed to do.
+# Also artistids are not used, but they are in the database.
+
+# Note: I wanted to use polars first but it does not support alpine images yet, so we use pandas instead.
+
+
+ARTIST_SEPARATORS: list[str] = get_config()["gui"]["library"][
+ "artist_separators"
+].as_str_seq()
+
+
+def _split_pattern(separators: list[str]) -> str:
+ return "|".join(map(re.escape, separators))
+
+
+split_pattern_artists = _split_pattern(ARTIST_SEPARATORS)
+
+
+def get_artists_pandas(table: str, artist: str | None = None) -> pd.DataFrame:
+ """Get all artists from the database using pandas.
+
+ Returns
+ -------
+ DataFrame with columns ['artist', 'count', 'last_added']
+ """
+ if table == "items":
+ query = """
+ SELECT
+ artist,
+ added
+ FROM
+ items
+ """
+ elif table == "albums":
+ query = """
+ SELECT
+ albumartist AS artist,
+ added
+
+ FROM
+ albums
+ """
+ else:
+ raise ValueError(f"Invalid table name: {table}. Must be 'items' or 'albums'.")
+
+ # Split the artist string by the specified separators
+ artists: list[str] | None
+ if len(ARTIST_SEPARATORS) > 0 and artist is not None:
+ artists = [a.strip() for a in re.split(split_pattern_artists, artist)]
+ elif artist is not None:
+ artists = [artist.strip()]
+ else:
+ artists = None
+
+ if artists is not None:
+ # If an artist is specified, filter the query
+ for i, a in enumerate(artists):
+ if i == 0:
+ query += f" WHERE instr(artist, ?) > 0"
+ else:
+ query += f" AND instr(artist, ?) > 0"
+
+ with g.lib.transaction() as tx:
+ rows = tx.query(query, artists) if artists else tx.query(query)
+
+ # Read from the database
+ df = pd.DataFrame(rows, columns=["artist", "added"])
+
+ # Split artist strings into lists and explode into separate rows
+ if len(ARTIST_SEPARATORS) > 0:
+ df["artist"] = df["artist"].str.split(split_pattern_artists)
+ df = df.explode("artist")
+
+ # Strip whitespace
+ df["artist"] = df["artist"].str.strip()
+ df["added"] = df["added"] * 1000
+
+ # Group by artist and aggregate
+ result = (
+ df.groupby("artist")
+ .agg(
+ count=("artist", "size"),
+ last_added=("added", "max"),
+ first_added=("added", "min"),
+ )
+ .reset_index()
+ )
+
+ if artists is not None:
+ # If an artist is specified, filter the result (respect the separator and resolve as or)
+ result = result[
+ result["artist"].str.contains(
+ _split_pattern(artists), case=False, regex=True
+ )
+ ]
+ # Overwrite if there are multiple artists (i.e. joined by a separator)
+ if len(artists) > 1 and not result.empty:
+ result["artist"] = artist
+
+ return result
+
+
+@artists_bp.route("/artists/", methods=["GET"])
+@artists_bp.route("/artists", methods=["GET"], defaults={"artist_name": None})
+async def all_artists(artist_name: str | None = None):
+ """Get all artists from the database.
+
+ This endpoint retrieves all artists from the database, splits them by
+ specified separators and aggregates the data to count the number of items.
+ """
+ artists_albums = (
+ get_artists_pandas("albums", artist_name)
+ .rename(
+ columns={
+ "count": "album_count",
+ "last_added": "last_album_added",
+ "first_added": "first_album_added",
+ }
+ )
+ .set_index("artist")
+ )
+ artists_items = (
+ get_artists_pandas("items", artist_name)
+ .rename(
+ columns={
+ "count": "item_count",
+ "last_added": "last_item_added",
+ "first_added": "first_item_added",
+ }
+ )
+ .set_index("artist")
+ )
+ # Join the two DataFrames on artist name and count the number of items and albums
+ artists = artists_albums.join(
+ artists_items,
+ how="outer",
+ ).reset_index()
+
+ # Fill n_albums and n_items with 0 if they are NaN
+ artists["album_count"] = artists["album_count"].fillna(0).astype(int)
+ artists["item_count"] = artists["item_count"].fillna(0).astype(int)
+
+ if artist_name is not None:
+ if artists.empty:
+ raise NotFoundException(f"Artist '{artist_name}' not found.")
+ else:
+ return Response(artists.iloc[0].to_json(), mimetype="application/json")
+
+ return Response(artists.to_json(orient="records"), mimetype="application/json")
diff --git a/backend/beets_flask/server/routes/library/artwork.py b/backend/beets_flask/server/routes/library/artwork.py
index 6ef315bb..1f326074 100644
--- a/backend/beets_flask/server/routes/library/artwork.py
+++ b/backend/beets_flask/server/routes/library/artwork.py
@@ -1,204 +1,204 @@
-import os
-from io import BytesIO
-from typing import TYPE_CHECKING, cast
-
-from beets import util as beets_util
-from mediafile import Image, MediaFile # comes with the beets install
-from PIL import Image as PILImage
-from quart import (
- Blueprint,
- g,
- jsonify,
- make_response,
- redirect,
- request,
- send_file,
- url_for,
-)
-
-from beets_flask.logger import log
-from beets_flask.server.exceptions import (
- IntegrityException,
- InvalidUsageException,
- NotFoundException,
-)
-
-if TYPE_CHECKING:
- # For type hinting the global g object
- from . import g
-
-__all__ = ["artwork_pb"]
-
-artwork_pb = Blueprint("artwork", __name__)
-
-
-# Predefined supported sizes
-SIZE_PRESETS = {
- "small": (256, 256),
- "medium": (512, 512),
- "large": (1024, 1024),
- "original": None,
-}
-
-
-def parse_size(size_key: str) -> tuple[int, int] | None:
- """Return a size tuple for a given preset key.
-
- or None for original.
- Raises KeyError if unknown.
- """
- preset = SIZE_PRESETS.get(size_key)
- if preset is None and size_key != "original":
- raise KeyError(f"Unknown size preset '{size_key}'")
- return preset
-
-
-def parse_art_params() -> tuple[int, tuple[int, int] | None]:
- """Parse common artwork parameters from request."""
- idx = int(request.args.get("index", 0))
- size_key = request.args.get("size", "small")
- try:
- size = parse_size(size_key)
- except KeyError:
- raise InvalidUsageException(
- f"Invalid size key '{size_key}' provided. Supported keys: {', '.join(SIZE_PRESETS.keys())}"
- )
- return idx, size
-
-
-def get_image_data_from_file(filepath: str, index: int = 0) -> BytesIO:
- """Get image data from a file path."""
- if not os.path.exists(filepath):
- raise IntegrityException(f"File '{filepath}' does not exist.")
-
- mediafile = MediaFile(filepath)
- images = mediafile.images
- if not images or len(images) <= index:
- raise NotFoundException(
- f"File has no cover art at index {index}: '{filepath}'."
- )
-
- im: Image = cast(Image, images[index])
- return BytesIO(im.data)
-
-
-def get_image_count_from_file(filepath: str) -> int:
- """Get number of images from a file path."""
- mediafile = MediaFile(filepath)
- images = mediafile.images
- return len(images) if images else 0
-
-
-async def send_image(img_data: BytesIO, size: tuple[int, int] | None = None):
- # Resize if preset provided
- if size:
- img_data = _resize(img_data, size)
- response = await make_response(await send_file(img_data, mimetype="image/png"))
- response.headers["Cache-Control"] = "public, max-age=86400"
- return response
-
-
-# -------------------------------- Item Routes ------------------------------- #
-
-
-@artwork_pb.route("/item//nArtworks", methods=["GET"])
-async def item_art_idx(item_id: int):
- """Get the number of images for an item."""
- log.debug(f"Item art index query for id:'{item_id}'")
-
- item = g.lib.get_item(item_id)
- if not item:
- raise NotFoundException(
- f"Item with beets_id:'{item_id}' not found in beets db."
- )
-
- item_path = beets_util.syspath(item.path)
- count = get_image_count_from_file(item_path)
- return jsonify({"count": count}), 200
-
-
-@artwork_pb.route("/item//art", methods=["GET"])
-async def item_art(item_id: int):
- log.debug(f"Item art query for id:'{item_id}'")
-
- idx, size = parse_art_params()
-
- item = g.lib.get_item(item_id)
- if not item:
- raise NotFoundException(
- f"Item with beets_id:'{item_id}' not found in beets db."
- )
-
- item_path = beets_util.syspath(item.path)
- img_data = get_image_data_from_file(item_path, idx)
- return await send_image(img_data, size)
-
-
-# ------------------------------- Album Routes ------------------------------- #
-
-
-@artwork_pb.route("/album//art", methods=["GET"])
-async def album_art(album_id: int):
- log.debug(f"Album art query for id:'{album_id}'")
-
- idx, size = parse_art_params()
-
- album = g.lib.get_album(album_id)
- if not album:
- raise NotFoundException(
- f"Album with beets_id:'{album_id}' not found in beets db."
- )
-
- # Has art set on album level
- if album.artpath and idx == 0:
- art_path = beets_util.syspath(album.artpath)
- if not os.path.exists(art_path):
- raise IntegrityException(
- f"Album art file '{art_path}' does not exist for album beets_id:'{album_id}'."
- )
- return await send_image(BytesIO(open(art_path, "rb").read()), size)
-
- # Otherwise use embedded from track
- items = album.items()
- if not items or len(items) < 1:
- raise IntegrityException(f"Album has no items: '{album_id}'.")
-
- return redirect(
- url_for(
- ".item_art",
- item_id=items[0].id,
- index=idx,
- size=request.args.get("size", "small"),
- )
- )
-
-
-# -------------------------------- File Routes ------------------------------- #
-
-
-@artwork_pb.route("/files//nArtworks", methods=["GET"])
-async def file_art_idx(filepath: str):
- """Get the number of images for a file."""
- filepath = bytes.fromhex(filepath).decode("utf-8")
- count = get_image_count_from_file(filepath)
- return jsonify({"count": count}), 200
-
-
-@artwork_pb.route("/file//art", methods=["GET"])
-async def file_art(filepath: str):
- filepath = bytes.fromhex(filepath).decode("utf-8")
- idx, size = parse_art_params()
- img_data = get_image_data_from_file(filepath, idx)
- return await send_image(img_data, size)
-
-
-# ---------------------------------- Utils ----------------------------------- #
-
-
-def _resize(img_data: BytesIO, size: tuple[int, int]) -> BytesIO:
- image = PILImage.open(img_data)
- image.thumbnail(size)
- image_io = BytesIO()
- image.convert("RGB").save(image_io, format="png")
- image_io.seek(0)
- return image_io
+import os
+from io import BytesIO
+from typing import TYPE_CHECKING, cast
+
+from beets import util as beets_util
+from mediafile import Image, MediaFile # comes with the beets install
+from PIL import Image as PILImage
+from quart import (
+ Blueprint,
+ g,
+ jsonify,
+ make_response,
+ redirect,
+ request,
+ send_file,
+ url_for,
+)
+
+from beets_flask.logger import log
+from beets_flask.server.exceptions import (
+ IntegrityException,
+ InvalidUsageException,
+ NotFoundException,
+)
+
+if TYPE_CHECKING:
+ # For type hinting the global g object
+ from . import g
+
+__all__ = ["artwork_pb"]
+
+artwork_pb = Blueprint("artwork", __name__)
+
+
+# Predefined supported sizes
+SIZE_PRESETS = {
+ "small": (256, 256),
+ "medium": (512, 512),
+ "large": (1024, 1024),
+ "original": None,
+}
+
+
+def parse_size(size_key: str) -> tuple[int, int] | None:
+ """Return a size tuple for a given preset key.
+
+ or None for original.
+ Raises KeyError if unknown.
+ """
+ preset = SIZE_PRESETS.get(size_key)
+ if preset is None and size_key != "original":
+ raise KeyError(f"Unknown size preset '{size_key}'")
+ return preset
+
+
+def parse_art_params() -> tuple[int, tuple[int, int] | None]:
+ """Parse common artwork parameters from request."""
+ idx = int(request.args.get("index", 0))
+ size_key = request.args.get("size", "small")
+ try:
+ size = parse_size(size_key)
+ except KeyError:
+ raise InvalidUsageException(
+ f"Invalid size key '{size_key}' provided. Supported keys: {', '.join(SIZE_PRESETS.keys())}"
+ )
+ return idx, size
+
+
+def get_image_data_from_file(filepath: str, index: int = 0) -> BytesIO:
+ """Get image data from a file path."""
+ if not os.path.exists(filepath):
+ raise IntegrityException(f"File '{filepath}' does not exist.")
+
+ mediafile = MediaFile(filepath)
+ images = mediafile.images
+ if not images or len(images) <= index:
+ raise NotFoundException(
+ f"File has no cover art at index {index}: '{filepath}'."
+ )
+
+ im: Image = cast(Image, images[index])
+ return BytesIO(im.data)
+
+
+def get_image_count_from_file(filepath: str) -> int:
+ """Get number of images from a file path."""
+ mediafile = MediaFile(filepath)
+ images = mediafile.images
+ return len(images) if images else 0
+
+
+async def send_image(img_data: BytesIO, size: tuple[int, int] | None = None):
+ # Resize if preset provided
+ if size:
+ img_data = _resize(img_data, size)
+ response = await make_response(await send_file(img_data, mimetype="image/png"))
+ response.headers["Cache-Control"] = "public, max-age=86400"
+ return response
+
+
+# -------------------------------- Item Routes ------------------------------- #
+
+
+@artwork_pb.route("/item//nArtworks", methods=["GET"])
+async def item_art_idx(item_id: int):
+ """Get the number of images for an item."""
+ log.debug(f"Item art index query for id:'{item_id}'")
+
+ item = g.lib.get_item(item_id)
+ if not item:
+ raise NotFoundException(
+ f"Item with beets_id:'{item_id}' not found in beets db."
+ )
+
+ item_path = beets_util.syspath(item.path)
+ count = get_image_count_from_file(item_path)
+ return jsonify({"count": count}), 200
+
+
+@artwork_pb.route("/item//art", methods=["GET"])
+async def item_art(item_id: int):
+ log.debug(f"Item art query for id:'{item_id}'")
+
+ idx, size = parse_art_params()
+
+ item = g.lib.get_item(item_id)
+ if not item:
+ raise NotFoundException(
+ f"Item with beets_id:'{item_id}' not found in beets db."
+ )
+
+ item_path = beets_util.syspath(item.path)
+ img_data = get_image_data_from_file(item_path, idx)
+ return await send_image(img_data, size)
+
+
+# ------------------------------- Album Routes ------------------------------- #
+
+
+@artwork_pb.route("/album//art", methods=["GET"])
+async def album_art(album_id: int):
+ log.debug(f"Album art query for id:'{album_id}'")
+
+ idx, size = parse_art_params()
+
+ album = g.lib.get_album(album_id)
+ if not album:
+ raise NotFoundException(
+ f"Album with beets_id:'{album_id}' not found in beets db."
+ )
+
+ # Has art set on album level
+ if album.artpath and idx == 0:
+ art_path = beets_util.syspath(album.artpath)
+ if not os.path.exists(art_path):
+ raise IntegrityException(
+ f"Album art file '{art_path}' does not exist for album beets_id:'{album_id}'."
+ )
+ return await send_image(BytesIO(open(art_path, "rb").read()), size)
+
+ # Otherwise use embedded from track
+ items = album.items()
+ if not items or len(items) < 1:
+ raise IntegrityException(f"Album has no items: '{album_id}'.")
+
+ return redirect(
+ url_for(
+ ".item_art",
+ item_id=items[0].id,
+ index=idx,
+ size=request.args.get("size", "small"),
+ )
+ )
+
+
+# -------------------------------- File Routes ------------------------------- #
+
+
+@artwork_pb.route("/files//nArtworks", methods=["GET"])
+async def file_art_idx(filepath: str):
+ """Get the number of images for a file."""
+ filepath = bytes.fromhex(filepath).decode("utf-8")
+ count = get_image_count_from_file(filepath)
+ return jsonify({"count": count}), 200
+
+
+@artwork_pb.route("/file//art", methods=["GET"])
+async def file_art(filepath: str):
+ filepath = bytes.fromhex(filepath).decode("utf-8")
+ idx, size = parse_art_params()
+ img_data = get_image_data_from_file(filepath, idx)
+ return await send_image(img_data, size)
+
+
+# ---------------------------------- Utils ----------------------------------- #
+
+
+def _resize(img_data: BytesIO, size: tuple[int, int]) -> BytesIO:
+ image = PILImage.open(img_data)
+ image.thumbnail(size)
+ image_io = BytesIO()
+ image.convert("RGB").save(image_io, format="png")
+ image_io.seek(0)
+ return image_io
diff --git a/backend/beets_flask/server/routes/library/audio.py b/backend/beets_flask/server/routes/library/audio.py
index 2aec5978..6e699914 100644
--- a/backend/beets_flask/server/routes/library/audio.py
+++ b/backend/beets_flask/server/routes/library/audio.py
@@ -1,366 +1,366 @@
-"""File streaming as mp3.
-
-Allows to stream an item's file as mp3.
-"""
-
-import asyncio
-import os
-import time
-from asyncio.subprocess import PIPE, Process
-from collections.abc import AsyncIterator, Hashable
-from typing import TYPE_CHECKING, Any, TypeVar
-
-import aiofiles
-import numpy as np
-from beets import util as beets_util
-from cachetools import Cache, TTLCache
-from cachetools.keys import hashkey
-from quart import Blueprint, Response, g
-
-from beets_flask.logger import log
-from beets_flask.server.exceptions import IntegrityException, NotFoundException
-
-audio_bp = Blueprint("audio", __name__)
-
-if TYPE_CHECKING:
- # For type hinting the global g object
- from . import g
-
-
-transcodeCache: Cache[Hashable, Any] = TTLCache(
- maxsize=128, ttl=60 * 60
-) # 1 hour cache
-peaksCache: Cache[Hashable, Any] = TTLCache(maxsize=128, ttl=60 * 60) # 1 hour cache
-
-
-@audio_bp.route("/item//audio", methods=["GET"])
-async def item_audio(item_id: int):
- """Get the raw item data.
-
- For streaming the audio file directly to the client.
- """
- item = g.lib.get_item(item_id)
- if not item:
- raise NotFoundException(
- f"Item with beets_id:'{item_id}' not found in beets db."
- )
-
- item_path = beets_util.syspath(item.path)
- if not os.path.exists(item_path):
- raise IntegrityException(
- f"Item file '{item_path}' does not exist for item beets_id:'{item_id}'."
- )
-
- it = await transcode_to_webm(item_path)
- return Response(
- cached_async_iterator(item_path, it, transcodeCache),
- mimetype="audio/webm",
- )
-
-
-@audio_bp.route("/item//audio/peaks", methods=["GET"])
-async def item_audio_peaks(item_id: int):
- """Get the raw item data.
-
- For streaming the audio file directly to the client.
- """
- item = g.lib.get_item(item_id)
- if not item:
- raise NotFoundException(
- f"Item with beets_id:'{item_id}' not found in beets db."
- )
-
- item_path = beets_util.syspath(item.path)
- if not os.path.exists(item_path):
- raise IntegrityException(
- f"Item file '{item_path}' does not exist for item beets_id:'{item_id}'."
- )
-
- peaks = await audio_peaks_cached(item_path)
- return Response(
- peaks.tobytes(),
- mimetype="application/octet-stream",
- )
-
-
-class FFmpegError(RuntimeError):
- def __init__(self, returncode: int, stderr: str):
- super().__init__(f"FFmpeg failed with code {returncode}: {stderr.strip()}")
- self.returncode = returncode
- self.stderr = stderr
-
-
-FATAL_PATTERNS = ["Error", "Invalid data", "partial file", "Could not"]
-
-
-class FFmpegStreamer:
- """A class to handle streaming audio files through FFmpeg.
-
- This class initializes a persistent FFmpeg process and streams audio files
- through it. It uses asyncio for asynchronous I/O operations.
- The FFmpeg process is started with the specified arguments and the input
- is written to its stdin. The output is read from its stdout in chunks.
- """
-
- process: Process | None
- _stderr_lines: list[str] = []
- chunk_size: int = 4096
-
- def __init__(self):
- self.process = None
-
- async def start(self, *ffmpeg_args):
- """Initialize a persistent FFmpeg process with stdin open for input."""
- self._stderr_lines = []
- self.process = await asyncio.create_subprocess_exec(
- "ffmpeg",
- *ffmpeg_args,
- stdin=PIPE,
- stdout=PIPE,
- stderr=PIPE,
- )
- asyncio.create_task(self._drain_stderr())
-
- async def stream_file(self, file_path: str | None) -> AsyncIterator[bytes]:
- """Stream an audio file through the pre-warmed FFmpeg process.
-
- If file_path is None, it will just stream from the existing process.
- """
-
- if (
- self.process is None
- or self.process.stdin is None
- or self.process.stdout is None
- ):
- raise RuntimeError("FFmpeg process not started. Call start() first.")
-
- if file_path is not None and os.path.exists(file_path):
- writer = asyncio.create_task(
- self._write_input(self._file_chunker(file_path))
- )
- else:
- writer = None
-
- start = time.process_time_ns()
- try:
- while not self.process.stdout.at_eof():
- chunk = await self.process.stdout.read(self.chunk_size)
- if not chunk:
- break
- yield chunk
- finally:
- if writer is not None:
- await writer
- return_code = await self.process.wait()
- if return_code != 0 or self._stderr_lines:
- raise FFmpegError(return_code, "".join(self._stderr_lines))
-
- end = time.process_time_ns()
- log.debug(f"Transcoded {file_path} in {(end - start) / 1_000_000_000:.2f}s")
-
- async def stream(self) -> AsyncIterator[bytes]:
- """Stream audio data from the FFmpeg process."""
- if (
- self.process is None
- or self.process.stdin is None
- or self.process.stdout is None
- ):
- raise RuntimeError("FFmpeg process not started. Call start() first.")
-
- start = time.process_time_ns()
- try:
- while not self.process.stdout.at_eof():
- chunk = await self.process.stdout.read(self.chunk_size)
- if not chunk:
- break
- yield chunk
- finally:
- return_code = await self.process.wait()
- if return_code != 0 or self._stderr_lines:
- raise FFmpegError(return_code, "".join(self._stderr_lines))
-
- end = time.process_time_ns()
- log.info(f"Streamed in {(end - start) / 1_000_000_000:.2f} s")
-
- async def _drain_stderr(self):
- """Continuously read and print FFmpeg stderr."""
- assert self.process and self.process.stderr
- while True:
- line = await self.process.stderr.readline()
- if not line:
- break
-
- decoded = line.decode().strip()
- self._stderr_lines.append(decoded)
- log.error(f"FFmpeg stderr: {decoded}")
-
- async def _write_input(self, input_stream: AsyncIterator[bytes]):
- assert self.process is not None
- assert self.process.stdin is not None
-
- async for data in input_stream:
- self.process.stdin.write(data)
- await self.process.stdin.drain()
- self.process.stdin.close()
-
- async def _file_chunker(
- self, path: str, size: int = 64 * 1024
- ) -> AsyncIterator[bytes]:
- # Use a semaphore to control read-ahead and prevent memory bloat
- semaphore = asyncio.Semaphore(size * 100)
-
- try:
- async with aiofiles.open(path, "rb") as file:
- while True:
- await semaphore.acquire()
- chunk = await file.read(size)
- if chunk == b"":
- break
- yield chunk
- semaphore.release()
-
- log.warning(f"Finished reading file {path}")
-
- except asyncio.CancelledError:
- log.info(f"File reading cancelled for {path}")
- raise
- except Exception as e:
- log.error(f"Error reading file {path}: {e}")
- raise
-
-
-T = TypeVar("T")
-
-
-async def cached_async_iterator(
- key: Hashable, iterator: AsyncIterator[T], cache: Cache[Hashable, list[T]]
-) -> AsyncIterator[T]:
- """Cache the results of an async iterator."""
- try:
- cached = []
- if key in cache:
- log.debug(f"Using cached data for {key}")
- cached = cache[key]
- else:
- log.debug(f"Caching data for {key}")
- async for item in iterator:
- cached.append(item)
- yield item
-
- cache[key] = cached
- return
-
- for item in cached:
- yield item
- except Exception:
- cache.pop(key, None)
- raise
-
-
-STREAMABLE_FORMATS = {"wav", "flac", "ogg", "pcm"} # extend if needed
-CONTAINER_FORMATS = {"m4a", "mp4", "mov", "alac", "aac", "mp3"} # require seek
-
-
-async def transcode_to_webm(file_path: str) -> AsyncIterator[bytes]:
- """Transcode a file to mp3 using FFmpeg and stream it."""
- ffmpeg_streamer = FFmpegStreamer()
- ext = file_path.split(".")[-1].lower()
-
- # This yields quite fast transcoding
- # for me, might need a bit more benchmarking
- # fmt: off
- if ext in STREAMABLE_FORMATS:
- await ffmpeg_streamer.start(*[
- "-hide_banner",
- "-loglevel", "error",
- "-fflags", "nobuffer",
- "-flush_packets", "0",
- "-probesize", "32",
- "-f", ext, "-i", "-", # stdin input
- "-vn", "-sn", "-dn", # DROP video streams
- "-preset", "ultrafast", # Faster MP3 encoding
- "-map_metadata", "-1", # Skip all metadata
- "-map", "0:a", # Map all audio streams
- "-codec:a", "libopus",
- "-b:a", "128k",
- "-f", "webm", # Force MP3 format for stdout
- "-", # Output to stdout
- ])
- # fmt: on
- return ffmpeg_streamer.stream_file(file_path)
- else:
- await ffmpeg_streamer.start(*[
- "-hide_banner",
- "-loglevel", "error",
- "-fflags", "nobuffer",
- "-flush_packets", "0",
- "-probesize", "32",
- "-i", str(file_path), # file input
- "-vn", "-sn", "-dn", # DROP video streams
- "-preset", "ultrafast", # Faster MP3 encoding
- "-map_metadata", "-1", # Skip all metadata
- "-map", "0:a", # Map all audio streams
- "-codec:a", "libopus",
- "-b:a", "128k",
- "-f", "webm", # Force MP3 format for stdout
- "-", # Output to stdout
- ])
- return ffmpeg_streamer.stream_file(file_path)
-
-
-peaksCache = TTLCache(maxsize=128, ttl=60 * 60) # 1 hour cache
-
-
-async def audio_peaks_cached(item_path: str) -> np.ndarray:
- """Helper function with LRU caching."""
- cache_key = hashkey(item_path)
- if cache_key in peaksCache:
- log.debug(f"Using cached peaks for {item_path}")
- return peaksCache[cache_key]
-
- result = await audio_peaks(item_path)
- peaksCache[cache_key] = result
- return result
-
-
-async def audio_peaks(path: str):
- ffmpeg_streamer = FFmpegStreamer()
-
- # fmt: off
- await ffmpeg_streamer.start(*[
- "-hide_banner",
- "-loglevel", "error",
- '-i', str(path),
- '-ac', "1",
- '-filter:a', 'aresample=8000',
- '-map','0:a',
- '-c:a',
- 'pcm_s16le',
- '-f', 'data', # Signed 16-bit little-endian PCM
- '-'
- ])
- # fmt: on
-
- # Convert to numpy array
- raw_samples = b"".join([chunk async for chunk in ffmpeg_streamer.stream()])
- # Normalize to -1.0 to 1.0 range
- samples = np.frombuffer(raw_samples, dtype=np.int16).astype(np.float32) / 32768.0
- # Downsample
- window_size = 2056
- starts = np.arange(0, samples.shape[0], window_size)
- maxs = np.maximum.reduceat(samples, starts, dtype=np.float32)
- return maxs
-
-
-async def chunked_bytes_iterator(
- data: bytes, chunk_size: int = 8192
-) -> AsyncIterator[bytes]:
- """
- Async iterator that yields chunks of bytes data.
-
- Args:
- data: The bytes object to be chunked
- chunk_size: Size of each chunk in bytes (default: 8KB)
- """
- for i in range(0, len(data), chunk_size):
- yield data[i : i + chunk_size]
+"""File streaming as mp3.
+
+Allows to stream an item's file as mp3.
+"""
+
+import asyncio
+import os
+import time
+from asyncio.subprocess import PIPE, Process
+from collections.abc import AsyncIterator, Hashable
+from typing import TYPE_CHECKING, Any, TypeVar
+
+import aiofiles
+import numpy as np
+from beets import util as beets_util
+from cachetools import Cache, TTLCache
+from cachetools.keys import hashkey
+from quart import Blueprint, Response, g
+
+from beets_flask.logger import log
+from beets_flask.server.exceptions import IntegrityException, NotFoundException
+
+audio_bp = Blueprint("audio", __name__)
+
+if TYPE_CHECKING:
+ # For type hinting the global g object
+ from . import g
+
+
+transcodeCache: Cache[Hashable, Any] = TTLCache(
+ maxsize=128, ttl=60 * 60
+) # 1 hour cache
+peaksCache: Cache[Hashable, Any] = TTLCache(maxsize=128, ttl=60 * 60) # 1 hour cache
+
+
+@audio_bp.route("/item//audio", methods=["GET"])
+async def item_audio(item_id: int):
+ """Get the raw item data.
+
+ For streaming the audio file directly to the client.
+ """
+ item = g.lib.get_item(item_id)
+ if not item:
+ raise NotFoundException(
+ f"Item with beets_id:'{item_id}' not found in beets db."
+ )
+
+ item_path = beets_util.syspath(item.path)
+ if not os.path.exists(item_path):
+ raise IntegrityException(
+ f"Item file '{item_path}' does not exist for item beets_id:'{item_id}'."
+ )
+
+ it = await transcode_to_webm(item_path)
+ return Response(
+ cached_async_iterator(item_path, it, transcodeCache),
+ mimetype="audio/webm",
+ )
+
+
+@audio_bp.route("/item//audio/peaks", methods=["GET"])
+async def item_audio_peaks(item_id: int):
+ """Get the raw item data.
+
+ For streaming the audio file directly to the client.
+ """
+ item = g.lib.get_item(item_id)
+ if not item:
+ raise NotFoundException(
+ f"Item with beets_id:'{item_id}' not found in beets db."
+ )
+
+ item_path = beets_util.syspath(item.path)
+ if not os.path.exists(item_path):
+ raise IntegrityException(
+ f"Item file '{item_path}' does not exist for item beets_id:'{item_id}'."
+ )
+
+ peaks = await audio_peaks_cached(item_path)
+ return Response(
+ peaks.tobytes(),
+ mimetype="application/octet-stream",
+ )
+
+
+class FFmpegError(RuntimeError):
+ def __init__(self, returncode: int, stderr: str):
+ super().__init__(f"FFmpeg failed with code {returncode}: {stderr.strip()}")
+ self.returncode = returncode
+ self.stderr = stderr
+
+
+FATAL_PATTERNS = ["Error", "Invalid data", "partial file", "Could not"]
+
+
+class FFmpegStreamer:
+ """A class to handle streaming audio files through FFmpeg.
+
+ This class initializes a persistent FFmpeg process and streams audio files
+ through it. It uses asyncio for asynchronous I/O operations.
+ The FFmpeg process is started with the specified arguments and the input
+ is written to its stdin. The output is read from its stdout in chunks.
+ """
+
+ process: Process | None
+ _stderr_lines: list[str] = []
+ chunk_size: int = 4096
+
+ def __init__(self):
+ self.process = None
+
+ async def start(self, *ffmpeg_args):
+ """Initialize a persistent FFmpeg process with stdin open for input."""
+ self._stderr_lines = []
+ self.process = await asyncio.create_subprocess_exec(
+ "ffmpeg",
+ *ffmpeg_args,
+ stdin=PIPE,
+ stdout=PIPE,
+ stderr=PIPE,
+ )
+ asyncio.create_task(self._drain_stderr())
+
+ async def stream_file(self, file_path: str | None) -> AsyncIterator[bytes]:
+ """Stream an audio file through the pre-warmed FFmpeg process.
+
+ If file_path is None, it will just stream from the existing process.
+ """
+
+ if (
+ self.process is None
+ or self.process.stdin is None
+ or self.process.stdout is None
+ ):
+ raise RuntimeError("FFmpeg process not started. Call start() first.")
+
+ if file_path is not None and os.path.exists(file_path):
+ writer = asyncio.create_task(
+ self._write_input(self._file_chunker(file_path))
+ )
+ else:
+ writer = None
+
+ start = time.process_time_ns()
+ try:
+ while not self.process.stdout.at_eof():
+ chunk = await self.process.stdout.read(self.chunk_size)
+ if not chunk:
+ break
+ yield chunk
+ finally:
+ if writer is not None:
+ await writer
+ return_code = await self.process.wait()
+ if return_code != 0 or self._stderr_lines:
+ raise FFmpegError(return_code, "".join(self._stderr_lines))
+
+ end = time.process_time_ns()
+ log.debug(f"Transcoded {file_path} in {(end - start) / 1_000_000_000:.2f}s")
+
+ async def stream(self) -> AsyncIterator[bytes]:
+ """Stream audio data from the FFmpeg process."""
+ if (
+ self.process is None
+ or self.process.stdin is None
+ or self.process.stdout is None
+ ):
+ raise RuntimeError("FFmpeg process not started. Call start() first.")
+
+ start = time.process_time_ns()
+ try:
+ while not self.process.stdout.at_eof():
+ chunk = await self.process.stdout.read(self.chunk_size)
+ if not chunk:
+ break
+ yield chunk
+ finally:
+ return_code = await self.process.wait()
+ if return_code != 0 or self._stderr_lines:
+ raise FFmpegError(return_code, "".join(self._stderr_lines))
+
+ end = time.process_time_ns()
+ log.info(f"Streamed in {(end - start) / 1_000_000_000:.2f} s")
+
+ async def _drain_stderr(self):
+ """Continuously read and print FFmpeg stderr."""
+ assert self.process and self.process.stderr
+ while True:
+ line = await self.process.stderr.readline()
+ if not line:
+ break
+
+ decoded = line.decode().strip()
+ self._stderr_lines.append(decoded)
+ log.error(f"FFmpeg stderr: {decoded}")
+
+ async def _write_input(self, input_stream: AsyncIterator[bytes]):
+ assert self.process is not None
+ assert self.process.stdin is not None
+
+ async for data in input_stream:
+ self.process.stdin.write(data)
+ await self.process.stdin.drain()
+ self.process.stdin.close()
+
+ async def _file_chunker(
+ self, path: str, size: int = 64 * 1024
+ ) -> AsyncIterator[bytes]:
+ # Use a semaphore to control read-ahead and prevent memory bloat
+ semaphore = asyncio.Semaphore(size * 100)
+
+ try:
+ async with aiofiles.open(path, "rb") as file:
+ while True:
+ await semaphore.acquire()
+ chunk = await file.read(size)
+ if chunk == b"":
+ break
+ yield chunk
+ semaphore.release()
+
+ log.warning(f"Finished reading file {path}")
+
+ except asyncio.CancelledError:
+ log.info(f"File reading cancelled for {path}")
+ raise
+ except Exception as e:
+ log.error(f"Error reading file {path}: {e}")
+ raise
+
+
+T = TypeVar("T")
+
+
+async def cached_async_iterator(
+ key: Hashable, iterator: AsyncIterator[T], cache: Cache[Hashable, list[T]]
+) -> AsyncIterator[T]:
+ """Cache the results of an async iterator."""
+ try:
+ cached = []
+ if key in cache:
+ log.debug(f"Using cached data for {key}")
+ cached = cache[key]
+ else:
+ log.debug(f"Caching data for {key}")
+ async for item in iterator:
+ cached.append(item)
+ yield item
+
+ cache[key] = cached
+ return
+
+ for item in cached:
+ yield item
+ except Exception:
+ cache.pop(key, None)
+ raise
+
+
+STREAMABLE_FORMATS = {"wav", "flac", "ogg", "pcm"} # extend if needed
+CONTAINER_FORMATS = {"m4a", "mp4", "mov", "alac", "aac", "mp3"} # require seek
+
+
+async def transcode_to_webm(file_path: str) -> AsyncIterator[bytes]:
+ """Transcode a file to mp3 using FFmpeg and stream it."""
+ ffmpeg_streamer = FFmpegStreamer()
+ ext = file_path.split(".")[-1].lower()
+
+ # This yields quite fast transcoding
+ # for me, might need a bit more benchmarking
+ # fmt: off
+ if ext in STREAMABLE_FORMATS:
+ await ffmpeg_streamer.start(*[
+ "-hide_banner",
+ "-loglevel", "error",
+ "-fflags", "nobuffer",
+ "-flush_packets", "0",
+ "-probesize", "32",
+ "-f", ext, "-i", "-", # stdin input
+ "-vn", "-sn", "-dn", # DROP video streams
+ "-preset", "ultrafast", # Faster MP3 encoding
+ "-map_metadata", "-1", # Skip all metadata
+ "-map", "0:a", # Map all audio streams
+ "-codec:a", "libopus",
+ "-b:a", "128k",
+ "-f", "webm", # Force MP3 format for stdout
+ "-", # Output to stdout
+ ])
+ # fmt: on
+ return ffmpeg_streamer.stream_file(file_path)
+ else:
+ await ffmpeg_streamer.start(*[
+ "-hide_banner",
+ "-loglevel", "error",
+ "-fflags", "nobuffer",
+ "-flush_packets", "0",
+ "-probesize", "32",
+ "-i", str(file_path), # file input
+ "-vn", "-sn", "-dn", # DROP video streams
+ "-preset", "ultrafast", # Faster MP3 encoding
+ "-map_metadata", "-1", # Skip all metadata
+ "-map", "0:a", # Map all audio streams
+ "-codec:a", "libopus",
+ "-b:a", "128k",
+ "-f", "webm", # Force MP3 format for stdout
+ "-", # Output to stdout
+ ])
+ return ffmpeg_streamer.stream_file(file_path)
+
+
+peaksCache = TTLCache(maxsize=128, ttl=60 * 60) # 1 hour cache
+
+
+async def audio_peaks_cached(item_path: str) -> np.ndarray:
+ """Helper function with LRU caching."""
+ cache_key = hashkey(item_path)
+ if cache_key in peaksCache:
+ log.debug(f"Using cached peaks for {item_path}")
+ return peaksCache[cache_key]
+
+ result = await audio_peaks(item_path)
+ peaksCache[cache_key] = result
+ return result
+
+
+async def audio_peaks(path: str):
+ ffmpeg_streamer = FFmpegStreamer()
+
+ # fmt: off
+ await ffmpeg_streamer.start(*[
+ "-hide_banner",
+ "-loglevel", "error",
+ '-i', str(path),
+ '-ac', "1",
+ '-filter:a', 'aresample=8000',
+ '-map','0:a',
+ '-c:a',
+ 'pcm_s16le',
+ '-f', 'data', # Signed 16-bit little-endian PCM
+ '-'
+ ])
+ # fmt: on
+
+ # Convert to numpy array
+ raw_samples = b"".join([chunk async for chunk in ffmpeg_streamer.stream()])
+ # Normalize to -1.0 to 1.0 range
+ samples = np.frombuffer(raw_samples, dtype=np.int16).astype(np.float32) / 32768.0
+ # Downsample
+ window_size = 2056
+ starts = np.arange(0, samples.shape[0], window_size)
+ maxs = np.maximum.reduceat(samples, starts, dtype=np.float32)
+ return maxs
+
+
+async def chunked_bytes_iterator(
+ data: bytes, chunk_size: int = 8192
+) -> AsyncIterator[bytes]:
+ """
+ Async iterator that yields chunks of bytes data.
+
+ Args:
+ data: The bytes object to be chunked
+ chunk_size: Size of each chunk in bytes (default: 8KB)
+ """
+ for i in range(0, len(data), chunk_size):
+ yield data[i : i + chunk_size]
diff --git a/backend/beets_flask/server/routes/library/metadata.py b/backend/beets_flask/server/routes/library/metadata.py
index fdf721f9..12cc1f47 100644
--- a/backend/beets_flask/server/routes/library/metadata.py
+++ b/backend/beets_flask/server/routes/library/metadata.py
@@ -1,69 +1,69 @@
-"""Get metadata for a library item.
-
-TODO: Allow to modify metadata or let ppl apply beets changes for a library item.
-"""
-
-import os
-from pathlib import Path
-from typing import TYPE_CHECKING
-
-from beets import util as beets_util
-from quart import Blueprint, g
-from tinytag import TinyTag
-
-from beets_flask.server.exceptions import IntegrityException, NotFoundException
-
-if TYPE_CHECKING:
- # For type hinting the global g object
- from . import g
-
-
-metadata_bp = Blueprint("metadata", __name__)
-
-
-__all__ = ["metadata_bp"]
-
-
-@metadata_bp.route("/item//metadata", methods=["GET"])
-async def item_metadata(item_id: int):
- # Item from beets library
- # FIXME: The following should be made into a common function
- # it is also used in artwork.py
- item = g.lib.get_item(item_id)
- if not item:
- raise NotFoundException(
- f"Item with beets_id:'{item_id}' not found in beets db."
- )
-
- # File path
- item_path = beets_util.syspath(item.path)
- if not os.path.exists(item_path):
- raise IntegrityException(
- f"Item file '{item_path}' does not exist for item beets_id:'{item_id}'."
- )
-
- return _get_metadata(item_path)
-
-
-@metadata_bp.route("/file//metadata", methods=["GET"])
-async def file_metadata(filepath: str):
- """Get metadata for a file given its path.
-
- It is hard to encode file paths with special characters in URLs so we use
- hex here and decode it back.
- """
- filepath = bytes.fromhex(filepath).decode("utf-8")
-
- if not os.path.exists(filepath):
- raise NotFoundException(f"File '{filepath}' does not exist.")
-
- return _get_metadata(filepath)
-
-
-def _get_metadata(file: str | Path):
- """Get the file metadata for a given audio file."""
- tags = TinyTag.get(file).as_dict()
-
- # Only include name in filename
- tags["filename"] = os.path.basename(file)
- return tags
+"""Get metadata for a library item.
+
+TODO: Allow to modify metadata or let ppl apply beets changes for a library item.
+"""
+
+import os
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from beets import util as beets_util
+from quart import Blueprint, g
+from tinytag import TinyTag
+
+from beets_flask.server.exceptions import IntegrityException, NotFoundException
+
+if TYPE_CHECKING:
+ # For type hinting the global g object
+ from . import g
+
+
+metadata_bp = Blueprint("metadata", __name__)
+
+
+__all__ = ["metadata_bp"]
+
+
+@metadata_bp.route("/item//metadata", methods=["GET"])
+async def item_metadata(item_id: int):
+ # Item from beets library
+ # FIXME: The following should be made into a common function
+ # it is also used in artwork.py
+ item = g.lib.get_item(item_id)
+ if not item:
+ raise NotFoundException(
+ f"Item with beets_id:'{item_id}' not found in beets db."
+ )
+
+ # File path
+ item_path = beets_util.syspath(item.path)
+ if not os.path.exists(item_path):
+ raise IntegrityException(
+ f"Item file '{item_path}' does not exist for item beets_id:'{item_id}'."
+ )
+
+ return _get_metadata(item_path)
+
+
+@metadata_bp.route("/file//metadata", methods=["GET"])
+async def file_metadata(filepath: str):
+ """Get metadata for a file given its path.
+
+ It is hard to encode file paths with special characters in URLs so we use
+ hex here and decode it back.
+ """
+ filepath = bytes.fromhex(filepath).decode("utf-8")
+
+ if not os.path.exists(filepath):
+ raise NotFoundException(f"File '{filepath}' does not exist.")
+
+ return _get_metadata(filepath)
+
+
+def _get_metadata(file: str | Path):
+ """Get the file metadata for a given audio file."""
+ tags = TinyTag.get(file).as_dict()
+
+ # Only include name in filename
+ tags["filename"] = os.path.basename(file)
+ return tags
diff --git a/backend/beets_flask/server/routes/library/resources.py b/backend/beets_flask/server/routes/library/resources.py
index c385d096..e297dd74 100644
--- a/backend/beets_flask/server/routes/library/resources.py
+++ b/backend/beets_flask/server/routes/library/resources.py
@@ -1,940 +1,948 @@
-"""Set update and delete methods for the beets library models.
-
-Adapted from the official beets web interface
-"""
-
-from __future__ import annotations
-
-import base64
-import datetime
-import os
-from collections.abc import Awaitable, Callable, Sequence
-from dataclasses import dataclass
-from functools import wraps
-from typing import (
- TYPE_CHECKING,
- Any,
- Literal,
- NotRequired,
- ParamSpec,
- TypedDict,
- TypeVar,
- cast,
-)
-
-from beets import util as beets_util
-from beets.dbcore import Model, Query, Results
-from beets.dbcore.query import Sort
-from beets.library import Album, Item, Library, parse_query_string
-from quart import Blueprint, Response, abort, g, json, jsonify, request
-
-from beets_flask.config import get_config
-from beets_flask.logger import log
-from beets_flask.server.exceptions import NotFoundException
-from beets_flask.server.routes.exception import InvalidUsageException
-from beets_flask.server.utility import pop_query_param
-
-if TYPE_CHECKING:
- # For type hinting the global g object
- from . import g
-
-
-resource_bp = Blueprint("resource", __name__)
-
-
-T = TypeVar("T", bound=Item | Album)
-
-
-def delete_files():
- """Return whether the current delete request should remove the selected files."""
- return request.args.get("delete") is not None
-
-
-def expanded_response():
- """Check if request is for an expanded response.
-
- Return whether the current request is for an expanded response.
- """
- return request.args.get("expand") is not None
-
-
-def minimal_response():
- """Check if request is for a minimal response.
-
- Normal requests contain full info, minimal ones only have item ids and names.
- """
- return request.args.get("minimal") is not None
-
-
-def resource_query(
- type: type[T], patchable: bool = False
-) -> Callable[..., Callable[[str], Awaitable[Response]]]:
- """Decorate a function to handle RESTful HTTP queries for resources."""
-
- def make_response(
- query_func: Callable[[str], Awaitable[Results[T]]],
- ) -> Callable[[str], Awaitable[Response]]:
- @wraps(query_func)
- async def wrapper(query: str) -> Response:
- # we set the route to use a path converter before us,
- # so queries is a single string.
- # edgecase: trailing escape character `\` would crash. we should
- # also avoid this in the frontend.
- if query.endswith("\\") and (len(query) - len(query.rstrip("\\"))) % 2 == 1:
- # only remove the last character if it is a single escape character
- query = query[:-1]
-
- entities: Sequence[T] = [e for e in await query_func(query)]
-
- method = request.method
-
- if method == "DELETE":
- delete_entities(entities, delete_files())
- return jsonify({"deleted": True})
- elif method == "PATCH" and patchable:
- entities = update_entities(entities, await request.get_json())
- elif method == "GET":
- pass
- else:
- return abort(405)
-
- # Return the entities
- return jsonify(
- [
- _rep(
- entity,
- expand=expanded_response(),
- minimal=minimal_response(),
- )
- for entity in entities
- ]
- )
-
- return wrapper
-
- return make_response
-
-
-P = ParamSpec("P")
-
-
-def resource(
- type: type[T], patchable: bool = False
-) -> Callable[..., Callable[P, Awaitable[Response]]]:
- """Decorate a function to handle RESTful HTTP requests for resources."""
-
- def make_response(
- get_func: Callable[P, Awaitable[T]],
- ) -> Callable[P, Awaitable[Response]]:
- @wraps(get_func)
- async def wrapper(*args: P.args, **kwargs: P.kwargs) -> Response:
- entity = await get_func(*args, **kwargs)
-
- method = request.method
-
- if method == "DELETE":
- delete_entities([entity], delete_files())
- return jsonify({"deleted": True})
- elif method == "PATCH":
- entity = update_entities([entity], await request.get_json())[0]
- elif method == "GET":
- pass
- else:
- return abort(405)
-
- # Return the entity
- return jsonify(
- _rep(
- entity,
- expand=expanded_response(),
- minimal=minimal_response(),
- )
- )
-
- return wrapper
-
- return make_response
-
-
-# ---------------------------------- Albums ---------------------------------- #
-
-
-@resource_bp.route("/album/", methods=["GET", "DELETE", "PATCH"])
-@resource(Album, patchable=False)
-async def album(id: int):
- item = g.lib.get_album(id)
- if not item:
- raise NotFoundException(f"Album with beets_id:'{id}' not found in beets db.")
- return item
-
-
-@resource_bp.route("/album/bf_id/", methods=["GET"])
-@resource(Album, patchable=False)
-async def album_by_bf_id(bf_id: str):
- """Get album by beets flask import id.
-
- Only works if album was imported with beets flask.
- """
- albums = g.lib.albums(f"gui_import_id:{bf_id}")
- if len(albums) == 0:
- raise NotFoundException(
- f"Album with gui_import_id:'{bf_id}' not found in beets db."
- )
-
- if len(albums) > 1:
- log.warning(
- f"Multiple albums with gui_import_id:'{bf_id}' found in beets db. "
- f"Returning first one."
- )
- album = albums[0]
- return album
-
-
-@resource_bp.route("/albums", methods=["GET"], defaults={"query": ""})
-@resource_bp.route("/albums/", methods=["GET"])
-async def all_albums(query: str = ""):
- """Get all albums in the library.
-
- If a query is provided, it will be used to filter the albums.
- """
- log.debug(f"Album query: {query}")
- params = dict(request.args)
-
- cursor = pop_query_param(params, "cursor", Cursor.from_string, None)
-
- if cursor is None:
- order_by_column = pop_query_param(params, "order_by", str, "added")
- order_by_direction = pop_query_param(params, "order_dir", str, "DESC")
- cursor = Cursor(
- order_by_column=order_by_column,
- order_by_direction=order_by_direction,
- last_order_by_value=None,
- last_id=None,
- )
-
- n_items = pop_query_param(
- params,
- "n_items",
- int,
- 50, # Default number of items per page
- )
-
- if len(params) > 0:
- raise InvalidUsageException(
- "Unexpected query parameters: , ".join(params.keys())
- )
-
- sub_query = parse_query_string(query, Album)
-
- paginated_query = PaginatedQuery(
- cursor=cursor,
- sub_query=sub_query,
- n_items=n_items,
- )
- albums = list(g.lib.albums(paginated_query, paginated_query))
-
- # Update cursor
- next_url: str | None = None
-
- total = paginated_query.total(g.lib)
- if len(albums) == n_items and len(albums) > 0:
- last_album = albums[-1]
-
- cursor.last_order_by_value = str(
- getattr(last_album, cursor.order_by_column, None)
- )
- cursor.last_id = str(last_album.id)
- next_url = f"{request.path}?cursor={cursor.to_string()}&n_items={n_items}"
-
- return jsonify(
- {
- "albums": [_rep(album, expand=False, minimal=True) for album in albums],
- "next": next_url,
- "total": total,
- }
- )
-
-
-# Artists are handled slightly differently, as they are not a beets model but can be
-# derived from the items.
-@resource_bp.route("/artist//albums", methods=["GET"])
-async def albums_by_artist(artist_name: str):
- """Get all items for a specific artist."""
- log.debug(f"Album query for artist '{artist_name}'")
-
- with g.lib.transaction() as tx:
- rows = tx.query(
- f"SELECT id FROM albums WHERE instr(albumartist, ?) > 0",
- (artist_name,),
- )
-
- expanded = expanded_response()
- minimal = minimal_response()
-
- return jsonify(
- [
- _rep(g.lib.get_album(row[0]), expand=expanded, minimal=minimal)
- for row in rows
- ]
- )
-
-
-# ----------------------------------- Items ---------------------------------- #
-
-
-@resource_bp.route("/item/", methods=["GET", "DELETE", "PATCH"])
-@resource(Item, patchable=True)
-async def item(id: int):
- item = g.lib.get_item(id)
- if not item:
- raise NotFoundException(f"Item with beets_id:'{id}' not found in beets db.")
-
- return item
-
-
-@resource_bp.route("/items", methods=["GET"], defaults={"query": ""})
-@resource_bp.route("/items/", methods=["GET"])
-async def all_items(query: str = ""):
- """Get all items in the library.
-
- If a query is provided, it will be used to filter the items.
- """
- log.debug(f"Item query: {query}")
- params = dict(request.args)
- cursor = pop_query_param(params, "cursor", Cursor.from_string, None)
- if cursor is None:
- order_by_column = pop_query_param(params, "order_by", str, "added")
- order_by_direction = pop_query_param(params, "order_dir", str, "DESC")
- cursor = Cursor(
- order_by_column=order_by_column,
- order_by_direction=order_by_direction,
- last_order_by_value=None,
- last_id=None,
- )
-
- n_items = pop_query_param(
- params,
- "n_items",
- int,
- 50, # Default number of items per page
- )
-
- if len(params) > 0:
- raise InvalidUsageException(
- "Unexpected query parameters: , ".join(params.keys())
- )
-
- sub_query = parse_query_string(query, Item)
-
- paginated_query = PaginatedQuery(
- cursor=cursor,
- sub_query=sub_query,
- n_items=n_items,
- table="items",
- )
- items = list(g.lib.items(paginated_query, paginated_query))
-
- # Update cursor
- next_url: str | None = None
-
- total = paginated_query.total(g.lib)
- if len(items) == n_items and len(items) > 0:
- last_item = items[-1]
-
- cursor.last_order_by_value = str(
- getattr(last_item, cursor.order_by_column, None)
- )
- cursor.last_id = str(last_item.id)
- next_url = f"{request.path}?cursor={cursor.to_string()}&n_items={n_items}"
-
- return jsonify(
- {
- "items": [_rep(item, expand=False, minimal=True) for item in items],
- "next": next_url,
- "total": total,
- }
- )
-
-
-# Items by artist are handled slightly differently, as they are not a beets model but can be
-# derived from the items.
-@resource_bp.route("/artist//items", methods=["GET"])
-async def items_by_artist(artist_name: str):
- """Get all items for a specific artist."""
- log.debug(f"Item query for artist '{artist_name}'")
-
- with g.lib.transaction() as tx:
- rows = tx.query(
- f"SELECT id FROM items WHERE instr(artist, ?) > 0",
- (artist_name,),
- )
-
- expanded = expanded_response()
- minimal = minimal_response()
-
- return jsonify(
- [_rep(g.lib.get_item(row[0]), expand=expanded, minimal=minimal) for row in rows]
- )
-
-
-# ----------------------------------- Util ----------------------------------- #
-
-
-def delete_entities(entities: Sequence[Item | Album], delete_files=False) -> None:
- """Helper function to delete entities."""
- if get_config()["gui"]["library"]["readonly"].get(bool):
- raise ValueError("Library is read-only")
-
- # Remove
- [entity.remove(delete=delete_files) for entity in entities]
-
-
-def update_entities(entities: Sequence[T], data: dict) -> Sequence[T]:
- """Helper function to update entities."""
- if get_config()["gui"]["library"]["readonly"].get(bool):
- raise ValueError("Library is read-only")
-
- # Update
- for entity in entities:
- entity.update(data)
- entity.try_sync(True, False)
-
- return entities
-
-
-@dataclass
-class Cursor:
- """Cursor for paginated queries.
-
- Contains the datetime and id of the last item in the current page.
- """
-
- order_by_column: str
- order_by_direction: str
-
- last_order_by_value: str | None
- last_id: str | None
-
- def to_string(self) -> str:
- """Convert the cursor to a string representation."""
- s = (
- json.dumps(
- {
- "c": self.order_by_column,
- "d": self.order_by_direction,
- "v": self.last_order_by_value,
- "i": self.last_id,
- }
- )
- .encode("utf-8")
- .hex()
- )
- return s
-
- @staticmethod
- def from_string(s: str) -> Cursor:
- """Create a cursor from a string representation."""
-
- try:
- d = json.loads(bytes.fromhex(s).decode("utf-8"))
- # TODO: Validate the structure of d
- return Cursor(d["c"], d["d"], d.get("v", None), d.get("i", None))
- except Exception as e:
- raise ValueError(f"Invalid cursor string: {s}")
-
- def causes(self) -> tuple[str, Sequence[Any]]:
- """Return a string representation of the cursor."""
- if not self.last_order_by_value or not self.last_id:
- # If no last value or id is set, we cannot use the cursor
- return "1=1", ()
-
- if self.order_by_direction not in ["ASC", "DESC"]:
- raise ValueError(
- f"Invalid order_by_direction: {self.order_by_direction}. "
- "Must be 'ASC' or 'DESC'."
- )
-
- eq_sign = "<"
- if self.order_by_direction == "ASC":
- eq_sign = ">"
-
- return (
- f"({self.order_by_column} {eq_sign} ?) OR ({self.order_by_column} = ? AND id {eq_sign} ?)",
- (
- self.last_order_by_value,
- self.last_order_by_value,
- self.last_id,
- ),
- )
-
- def order_by_clause(self) -> str:
- """Return the order by clause for the query."""
-
- return f"{self.order_by_column} {self.order_by_direction}, id {self.order_by_direction}"
-
-
-class PaginatedQuery(Query, Sort):
- # Number of items to return per page.
- n_items: int
-
- # Current position in the query.
- cursor: Cursor
-
- _sub_query: tuple[Query, Sort] | None
-
- table: Literal["albums", "items"]
-
- def __init__(
- self,
- cursor: Cursor,
- sub_query: tuple[Query, Sort],
- n_items=50,
- table: Literal["albums", "items"] = "albums",
- ) -> None:
- super().__init__()
- self.n_items = n_items
- self.cursor = cursor
- self._sub_query = sub_query
- self.table = table
-
- def clause(self) -> tuple[str | None, Sequence[Any]]:
- """Return the SQL clause and values for the query."""
-
- if self._sub_query:
- # If there is a sub-query, use it to filter the results
- cs, vs = self._sub_query[0].clause()
- else:
- cs = "1=1" # No sub-query, match all
- vs = ()
-
- cc, cv = self.cursor.causes()
- return f"({cs}) AND ({cc})", list(vs) + list(cv)
-
- def order_clause(self) -> str:
- """Order by added date and id descending."""
- sub_sort = self._sub_query[1] if self._sub_query else None
- cursor_order = self.cursor.order_by_clause()
- if sub_sort:
- return f"{cursor_order} LIMIT {self.n_items}"
- return f"{cursor_order} LIMIT {self.n_items}"
-
- def match(self, obj: Model) -> bool: # type: ignore
- return isinstance(obj, Item) or isinstance(obj, Album)
-
- def total(self, lib: Library) -> int:
- """Return the total number of items in the query."""
-
- if self._sub_query:
- # If there is a sub-query, use it to filter the results
- cs, vs = self._sub_query[0].clause()
- else:
- cs = "1=1" # No sub-query, match all
- vs = ()
-
- with g.lib.transaction() as tx:
- count = tx.query(f"SELECT COUNT(*) FROM {self.table} WHERE {cs}", vs)[0][0]
- return count
-
-
-# -------------------- Helper for formatting beets models -------------------- #
-
-
-class ItemResponseMinimal(TypedDict):
- """Type definition for the minimal response for item."""
-
- # Unique identifier for the item in the beets library
- id: int
- # Name of the item
- name: str
- # Full path to the item on disk
- path: str
- # Primary artist for the item
- artist: str
- # Year the item was published
- year: int
-
- # Name, id and the primary artist
- # for the associated album
- album: str
- albumartist: str
- album_id: int
-
- # ISRC code for the item
- isrc: NotRequired[str]
-
- size: int
-
-
-class ItemResponse(ItemResponseMinimal):
- """Type definition for the full item response.
-
- Might not be 100% accurate as plugins may add additional fields. We
- atleast type all field that are used in the frontend.
- """
-
- # The genre of the item, if multiple genres are present they are
- # separated by a semicolon (;)
- genre: str
-
- # The label in which the item was published
- label: str
-
- # Technical details about the item
- samplerate: int
- bitrate: int
- bpm: int
- bitdepth: int
- channels: int
- format: str
- encoder_info: str
- encoder_settings: str
- initial_key: str
- length: float
-
- # Album specifics
- track: int
- tracktotal: int
-
- # Library specific
- added: float
-
- # Catalog number
- catalognum: str
-
- # The source of the item, e.g. CD, Vinyl, Digital
- sources: list[ItemSource]
-
-
-class ItemSource(TypedDict):
- source: str
- track_id: str
- album_id: NotRequired[str]
- artist_id: NotRequired[str]
-
- extra: NotRequired[dict[str, str | list[str]]]
-
-
-source_prefixes = ["mb", "spotify", "tidal", "discogs"]
-
-
-def _repr_Item(item: Item | None, minimal=False) -> ItemResponse | ItemResponseMinimal:
- if not item:
- raise NotFoundException("Item not found")
-
- out: dict[str, Any] = dict()
-
- if minimal:
- keys = [
- "id",
- "name",
- "artist",
- "albumartist",
- "album",
- "album_id",
- "year",
- "isrc",
- ]
- else:
- # Use all keys
- keys = item.keys(True) + ["name"]
-
- # Check data source prefixes:
- # plugins such as spotify, tidal, discogs add a prefix to the id,
- # we want to split this prefix from the id and add them to a list of
- # sources
- sources: list[ItemSource] = list()
- for prefix in source_prefixes:
- f_keys = list(filter(lambda k: k.startswith(f"{prefix}_"), keys))
-
- track_id, track_id_key = __get_id(item, prefix, "track")
- if not track_id:
- continue
- source = ItemSource(source=prefix, track_id=track_id)
-
- album_id, album_id_key = __get_id(item, prefix, "album")
- if album_id:
- source["album_id"] = album_id
-
- artist_id, artist_id_key = __get_id(item, prefix, "artist")
- if artist_id:
- source["artist_id"] = artist_id
-
- keys_extra = [
- k
- for k in f_keys
- if k not in [track_id_key, album_id_key, artist_id_key]
- ]
- extras = {}
- for k in keys_extra:
- if __is_empty(item[k]):
- continue
- extras[__normalize_id_key(prefix, k)] = item[k]
-
- if len(extras) > 0:
- source["extra"] = extras
-
- sources.append(source)
- keys = [k for k in keys if k not in f_keys]
-
- # additionally the mb_id fields may be filled with the same id
- # as the any other data source if mb is disabled, this is done
- # by beets to allow easier lookup
- mb_source = next(filter(lambda s: s["source"] == "mb", sources), None)
- if mb_source and len(sources) > 1:
- for source in sources:
- if source["source"] == "mb":
- continue
-
- if source["track_id"] == mb_source["track_id"]:
- # Update source with other unset mb fields
- # no idea why this happens but e.g. albumartist_id set for mb
- # but not for spotify even tho the mb_albumartistid is a spotify
- # id
- for k, v in mb_source.items():
- if k not in source:
- # Fixme: Typing is a bit cursed here
- source[k] = v # type: ignore
-
- sources = list(filter(lambda s: s["source"] != "mb", sources))
- break
-
- out["sources"] = sources
-
- for key in keys:
- if key == "name":
- out[key] = item.title
- else:
- out[key] = item[key]
-
- # Format path
- if key == "path":
- out[key] = beets_util.displayable_path(out[key])
-
- # Decode bytes
- b = out[key]
- if isinstance(b, bytes):
- out[key] = base64.b64encode(b).decode("ascii")
-
- # Remove empty values
- if __is_empty(out[key]):
- del out[key]
-
- # Get the size (in bytes) of the backing file. This is useful
- # for the Tomahawk resolver API.
- try:
- out["size"] = os.path.getsize(beets_util.syspath(path=item.path))
- except OSError:
- out["size"] = 0
-
- return cast(ItemResponse | ItemResponseMinimal, out)
-
-
-class AlbumResponseMinimal(TypedDict):
- """Type definition for the minimal response for album."""
-
- # Unique identifier for the album in the beets library
- id: int
- # Name of the album
- name: str
- # Primary artist for the album
- albumartist: str
- # Year the album was published
- year: int
- # Date the album was added to the library
- added: datetime.datetime
-
-
-class AlbumResponseMinimalExpanded(AlbumResponseMinimal):
- items: list[ItemResponseMinimal]
-
- gui_import_id: NotRequired[str]
- gui_import_date: NotRequired[str]
-
- # Not sure if these are always set
- albumtype: NotRequired[str]
-
-
-class AlbumResponse(AlbumResponseMinimal):
- """Type definition for the full album response.
-
- Might not be 100% accurate as plugins may add additional fields. We
- atleast type all field that are used in the frontend.
- """
-
- # The genre of the album, if multiple genres are present they are
- # separated by a semicolon (;)
- genre: str
-
- # The label in which the album was published
- label: str
-
- # The data source of the album metadata
- sources: list[AlbumSource]
-
-
-class AlbumResponseExpanded(AlbumResponse):
- items: list[ItemResponse]
-
- gui_import_id: NotRequired[str]
- gui_import_date: NotRequired[str]
-
- # Not sure if these are always set
- albumtype: NotRequired[str]
-
-
-class AlbumSource(TypedDict):
- source: str
- album_id: str
- artist_id: NotRequired[str]
-
- extra: NotRequired[dict[str, str]]
-
-
-def _rep_Album(
- album: Album, expand=False, minimal=False
-) -> AlbumResponse | AlbumResponseMinimal:
- """Get a flat -- i.e., JSON-ish -- representation of a beets Item/Album object.
-
- For Albums, `expand` dictates whether tracks are
- included.
- """
-
- out: dict[str, Any] = dict()
-
- if minimal:
- keys = ["id", "name", "albumartist", "year", "added"]
- else:
- # Use all keys
- keys = album.keys() + ["name"]
-
- # Parse sources
- out["sources"] = list()
- for prefix in source_prefixes:
- f_keys = list(filter(lambda k: k.startswith(f"{prefix}_"), keys))
-
- album_id, album_id_key = __get_id(album, prefix, "album")
- if not album_id:
- continue
- source = AlbumSource(source=prefix, album_id=album_id)
-
- artist_id, artist_id_key = __get_id(album, prefix, "artist")
- if artist_id:
- source["artist_id"] = artist_id
-
- keys_extra = [k for k in f_keys if k not in [album_id_key, artist_id_key]]
- extras = {}
- for k in keys_extra:
- if __is_empty(album[k]):
- continue
- extras[__normalize_id_key(prefix, k)] = album[k]
-
- if len(extras) > 0:
- source["extra"] = extras
-
- out["sources"].append(source)
- keys = [k for k in keys if k not in f_keys]
-
- # The mb source might be duplicated in other sources, e.g. spotify,
- # We delete the mb source if it is a duplicate of another source.
- mb_source = next(filter(lambda s: s["source"] == "mb", out["sources"]), None)
- if mb_source and len(out["sources"]) > 1:
- for source in out["sources"]:
- if source["source"] == "mb":
- continue
-
- if source["album_id"] == mb_source["album_id"]:
- # delete mb source
- out["sources"] = list(
- filter(lambda s: s["source"] != "mb", out["sources"])
- )
-
- for key in keys:
- if key == "name":
- out[key] = album.album
- else:
- out[key] = album[key]
-
- # Format path
- if key == "path":
- out[key] = beets_util.displayable_path(out[key])
-
- # Decode bytes
- if isinstance(out[key], bytes):
- out[key] = base64.b64encode(out[key]).decode("ascii")
-
- # Remove empty values
- if __is_empty(out[key]):
- del out[key]
-
- if key == "added":
- # Convert to datetime
- out[key] = datetime.datetime.fromtimestamp(out[key])
-
- if expand:
- out["items"] = [_repr_Item(item, minimal) for item in album.items()]
-
- return cast(AlbumResponse | AlbumResponseMinimal, out)
-
-
-def _rep(entity: Item | Album | None, expand=False, minimal=False):
- """Get a flat -- i.e., JSON-ish -- representation of a beets Item/Album object.
-
- For Albums, `expand` dictates whether tracks are
- included.
- """
-
- if not entity:
- raise NotFoundException("Entity not found")
-
- if isinstance(entity, Item):
- return _repr_Item(entity, minimal)
- elif isinstance(entity, Album):
- return _rep_Album(entity, expand, minimal)
- else:
- raise ValueError(f"Unknown entity type: {type(entity)}")
-
-
-def __is_empty(value: str | None | list[Any], zero_empty: bool = True) -> bool:
- """Check if empty value."""
- if value is None:
- return True
- if value == "":
- return True
- if isinstance(value, str) and value.isspace():
- return True
- if isinstance(value, list) and len(value) == 0:
- return True
- if zero_empty and isinstance(value, int) and value == 0:
- return True
-
- return False
-
-
-def __get_id(
- item: Item | Album,
- source: str,
- t: str,
-) -> tuple[str | None, str | None]:
- """Get the id of a source.
-
- Resolve inconsistencies in the beets library where the id is stored in
- different fields.
- """
- s1 = item.get(f"{source}_{t}_id", None)
- if s1:
- return s1, f"{source}_{t}_id"
-
- s2 = item.get(f"{source}_{t}id", None)
- if s2:
- return s2, f"{source}_{t}id"
-
- return None, None
-
-
-def __normalize_id_key(prefix: str, id: str):
- """Normalize the id key.
-
- Inserts an underscore before the "id" or "ids" suffix.
- Also removes the prefix.
- """
- return id.replace("id", "_id").replace(prefix + "_", "")
+"""Set update and delete methods for the beets library models.
+
+Adapted from the official beets web interface
+"""
+
+from __future__ import annotations
+
+import base64
+import datetime
+import os
+from collections.abc import Awaitable, Callable, Sequence
+from dataclasses import dataclass
+from functools import wraps
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ Literal,
+ NotRequired,
+ ParamSpec,
+ TypedDict,
+ TypeVar,
+ cast,
+)
+
+from beets import util as beets_util
+from beets.dbcore import Model, Query, Results
+from beets.dbcore.query import Sort
+from beets.library import Album, Item, Library, parse_query_string
+from quart import Blueprint, Response, abort, g, json, jsonify, request
+
+from beets_flask.config import get_config
+from beets_flask.logger import log
+from beets_flask.server.exceptions import NotFoundException
+from beets_flask.server.routes.exception import InvalidUsageException
+from beets_flask.server.utility import pop_query_param
+
+if TYPE_CHECKING:
+ # For type hinting the global g object
+ from . import g
+
+
+resource_bp = Blueprint("resource", __name__)
+
+
+T = TypeVar("T", bound=Item | Album)
+
+
+def delete_files():
+ """Return whether the current delete request should remove the selected files."""
+ return request.args.get("delete") is not None
+
+
+def expanded_response():
+ """Check if request is for an expanded response.
+
+ Return whether the current request is for an expanded response.
+ """
+ return request.args.get("expand") is not None
+
+
+def minimal_response():
+ """Check if request is for a minimal response.
+
+ Normal requests contain full info, minimal ones only have item ids and names.
+ """
+ return request.args.get("minimal") is not None
+
+
+def resource_query(
+ type: type[T], patchable: bool = False
+) -> Callable[..., Callable[[str], Awaitable[Response]]]:
+ """Decorate a function to handle RESTful HTTP queries for resources."""
+
+ def make_response(
+ query_func: Callable[[str], Awaitable[Results[T]]],
+ ) -> Callable[[str], Awaitable[Response]]:
+ @wraps(query_func)
+ async def wrapper(query: str) -> Response:
+ # we set the route to use a path converter before us,
+ # so queries is a single string.
+ # edgecase: trailing escape character `\` would crash. we should
+ # also avoid this in the frontend.
+ if query.endswith("\\") and (len(query) - len(query.rstrip("\\"))) % 2 == 1:
+ # only remove the last character if it is a single escape character
+ query = query[:-1]
+
+ entities: Sequence[T] = [e for e in await query_func(query)]
+
+ method = request.method
+
+ if method == "DELETE":
+ delete_entities(entities, delete_files())
+ return jsonify({"deleted": True})
+ elif method == "PATCH" and patchable:
+ entities = update_entities(entities, await request.get_json())
+ elif method == "GET":
+ pass
+ else:
+ return abort(405)
+
+ # Return the entities
+ return jsonify(
+ [
+ _rep(
+ entity,
+ expand=expanded_response(),
+ minimal=minimal_response(),
+ )
+ for entity in entities
+ ]
+ )
+
+ return wrapper
+
+ return make_response
+
+
+P = ParamSpec("P")
+
+
+def resource(
+ type: type[T], patchable: bool = False
+) -> Callable[..., Callable[P, Awaitable[Response]]]:
+ """Decorate a function to handle RESTful HTTP requests for resources."""
+
+ def make_response(
+ get_func: Callable[P, Awaitable[T]],
+ ) -> Callable[P, Awaitable[Response]]:
+ @wraps(get_func)
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> Response:
+ entity = await get_func(*args, **kwargs)
+
+ method = request.method
+
+ if method == "DELETE":
+ delete_entities([entity], delete_files())
+ return jsonify({"deleted": True})
+ elif method == "PATCH":
+ entity = update_entities([entity], await request.get_json())[0]
+ elif method == "GET":
+ pass
+ else:
+ return abort(405)
+
+ # Return the entity
+ return jsonify(
+ _rep(
+ entity,
+ expand=expanded_response(),
+ minimal=minimal_response(),
+ )
+ )
+
+ return wrapper
+
+ return make_response
+
+
+# ---------------------------------- Albums ---------------------------------- #
+
+
+@resource_bp.route("/album/", methods=["GET", "DELETE", "PATCH"])
+@resource(Album, patchable=False)
+async def album(id: int):
+ item = g.lib.get_album(id)
+ if not item:
+ raise NotFoundException(f"Album with beets_id:'{id}' not found in beets db.")
+ return item
+
+
+@resource_bp.route("/album/bf_id/", methods=["GET"])
+@resource(Album, patchable=False)
+async def album_by_bf_id(bf_id: str):
+ """Get album by beets flask import id.
+
+ Only works if album was imported with beets flask.
+ """
+ albums = g.lib.albums(f"gui_import_id:{bf_id}")
+ if len(albums) == 0:
+ raise NotFoundException(
+ f"Album with gui_import_id:'{bf_id}' not found in beets db."
+ )
+
+ if len(albums) > 1:
+ log.warning(
+ f"Multiple albums with gui_import_id:'{bf_id}' found in beets db. "
+ f"Returning first one."
+ )
+ album = albums[0]
+ return album
+
+
+@resource_bp.route("/albums", methods=["GET"], defaults={"query": ""})
+@resource_bp.route("/albums/", methods=["GET"])
+async def all_albums(query: str = ""):
+ """Get all albums in the library.
+
+ If a query is provided, it will be used to filter the albums.
+ """
+ log.debug(f"Album query: {query}")
+ params = dict(request.args)
+
+ cursor = pop_query_param(params, "cursor", Cursor.from_string, None)
+
+ if cursor is None:
+ order_by_column = pop_query_param(params, "order_by", str, "added")
+ order_by_direction = pop_query_param(params, "order_dir", str, "DESC")
+ cursor = Cursor(
+ order_by_column=order_by_column,
+ order_by_direction=order_by_direction,
+ last_order_by_value=None,
+ last_id=None,
+ )
+
+ n_items = pop_query_param(
+ params,
+ "n_items",
+ int,
+ 50, # Default number of items per page
+ )
+
+ if len(params) > 0:
+ raise InvalidUsageException(
+ "Unexpected query parameters: , ".join(params.keys())
+ )
+
+ sub_query = parse_query_string(query, Album)
+
+ paginated_query = PaginatedQuery(
+ cursor=cursor,
+ sub_query=sub_query,
+ n_items=n_items,
+ )
+ albums = list(g.lib.albums(paginated_query, paginated_query))
+
+ # Update cursor
+ next_url: str | None = None
+
+ total = paginated_query.total(g.lib)
+ if len(albums) == n_items and len(albums) > 0:
+ last_album = albums[-1]
+
+ cursor.last_order_by_value = str(
+ getattr(last_album, cursor.order_by_column, None)
+ )
+ cursor.last_id = str(last_album.id)
+ next_url = f"{request.path}?cursor={cursor.to_string()}&n_items={n_items}"
+
+ return jsonify(
+ {
+ "albums": [_rep(album, expand=False, minimal=True) for album in albums],
+ "next": next_url,
+ "total": total,
+ }
+ )
+
+
+# Artists are handled slightly differently, as they are not a beets model but can be
+# derived from the items.
+@resource_bp.route("/artist//albums", methods=["GET"])
+async def albums_by_artist(artist_name: str):
+ """Get all items for a specific artist."""
+ log.debug(f"Album query for artist '{artist_name}'")
+
+ with g.lib.transaction() as tx:
+ rows = tx.query(
+ f"SELECT id FROM albums WHERE instr(albumartist, ?) > 0",
+ (artist_name,),
+ )
+
+ expanded = expanded_response()
+ minimal = minimal_response()
+
+ return jsonify(
+ [
+ _rep(g.lib.get_album(row[0]), expand=expanded, minimal=minimal)
+ for row in rows
+ ]
+ )
+
+
+# ----------------------------------- Items ---------------------------------- #
+
+
+@resource_bp.route("/item/", methods=["GET", "DELETE", "PATCH"])
+@resource(Item, patchable=True)
+async def item(id: int):
+ item = g.lib.get_item(id)
+ if not item:
+ raise NotFoundException(f"Item with beets_id:'{id}' not found in beets db.")
+
+ return item
+
+
+@resource_bp.route("/items", methods=["GET"], defaults={"query": ""})
+@resource_bp.route("/items/", methods=["GET"])
+async def all_items(query: str = ""):
+ """Get all items in the library.
+
+ If a query is provided, it will be used to filter the items.
+ """
+ log.debug(f"Item query: {query}")
+ params = dict(request.args)
+ cursor = pop_query_param(params, "cursor", Cursor.from_string, None)
+ if cursor is None:
+ order_by_column = pop_query_param(params, "order_by", str, "added")
+ order_by_direction = pop_query_param(params, "order_dir", str, "DESC")
+ cursor = Cursor(
+ order_by_column=order_by_column,
+ order_by_direction=order_by_direction,
+ last_order_by_value=None,
+ last_id=None,
+ )
+
+ n_items = pop_query_param(
+ params,
+ "n_items",
+ int,
+ 50, # Default number of items per page
+ )
+
+ if len(params) > 0:
+ raise InvalidUsageException(
+ "Unexpected query parameters: , ".join(params.keys())
+ )
+
+ sub_query = parse_query_string(query, Item)
+
+ paginated_query = PaginatedQuery(
+ cursor=cursor,
+ sub_query=sub_query,
+ n_items=n_items,
+ table="items",
+ )
+ items = list(g.lib.items(paginated_query, paginated_query))
+
+ # Update cursor
+ next_url: str | None = None
+
+ total = paginated_query.total(g.lib)
+ if len(items) == n_items and len(items) > 0:
+ last_item = items[-1]
+
+ cursor.last_order_by_value = str(
+ getattr(last_item, cursor.order_by_column, None)
+ )
+ cursor.last_id = str(last_item.id)
+ next_url = f"{request.path}?cursor={cursor.to_string()}&n_items={n_items}"
+
+ return jsonify(
+ {
+ "items": [_rep(item, expand=False, minimal=True) for item in items],
+ "next": next_url,
+ "total": total,
+ }
+ )
+
+
+# Items by artist are handled slightly differently, as they are not a beets model but can be
+# derived from the items.
+@resource_bp.route("/artist//items", methods=["GET"])
+async def items_by_artist(artist_name: str):
+ """Get all items for a specific artist."""
+ log.debug(f"Item query for artist '{artist_name}'")
+
+ with g.lib.transaction() as tx:
+ rows = tx.query(
+ f"SELECT id FROM items WHERE instr(artist, ?) > 0",
+ (artist_name,),
+ )
+
+ expanded = expanded_response()
+ minimal = minimal_response()
+
+ return jsonify(
+ [_rep(g.lib.get_item(row[0]), expand=expanded, minimal=minimal) for row in rows]
+ )
+
+
+# ----------------------------------- Util ----------------------------------- #
+
+
+def delete_entities(entities: Sequence[Item | Album], delete_files=False) -> None:
+ """Helper function to delete entities."""
+ if get_config()["gui"]["library"]["readonly"].get(bool):
+ raise ValueError("Library is read-only")
+
+ # Remove
+ [entity.remove(delete=delete_files) for entity in entities]
+
+
+def update_entities(entities: Sequence[T], data: dict) -> Sequence[T]:
+ """Helper function to update entities."""
+ if get_config()["gui"]["library"]["readonly"].get(bool):
+ raise ValueError("Library is read-only")
+
+ # Update
+ for entity in entities:
+ entity.update(data)
+ entity.try_sync(True, False)
+
+ return entities
+
+
+@dataclass
+class Cursor:
+ """Cursor for paginated queries.
+
+ Contains the datetime and id of the last item in the current page.
+ """
+
+ order_by_column: str
+ order_by_direction: str
+
+ last_order_by_value: str | None
+ last_id: str | None
+
+ def to_string(self) -> str:
+ """Convert the cursor to a string representation."""
+ s = (
+ json.dumps(
+ {
+ "c": self.order_by_column,
+ "d": self.order_by_direction,
+ "v": self.last_order_by_value,
+ "i": self.last_id,
+ }
+ )
+ .encode("utf-8")
+ .hex()
+ )
+ return s
+
+ @staticmethod
+ def from_string(s: str) -> Cursor:
+ """Create a cursor from a string representation."""
+
+ try:
+ d = json.loads(bytes.fromhex(s).decode("utf-8"))
+ # TODO: Validate the structure of d
+ return Cursor(d["c"], d["d"], d.get("v", None), d.get("i", None))
+ except Exception as e:
+ raise ValueError(f"Invalid cursor string: {s}")
+
+ def causes(self) -> tuple[str, Sequence[Any]]:
+ """Return a string representation of the cursor."""
+ if not self.last_order_by_value or not self.last_id:
+ # If no last value or id is set, we cannot use the cursor
+ return "1=1", ()
+
+ if self.order_by_direction not in ["ASC", "DESC"]:
+ raise ValueError(
+ f"Invalid order_by_direction: {self.order_by_direction}. "
+ "Must be 'ASC' or 'DESC'."
+ )
+
+ eq_sign = "<"
+ if self.order_by_direction == "ASC":
+ eq_sign = ">"
+
+ return (
+ f"({self.order_by_column} {eq_sign} ?) OR ({self.order_by_column} = ? AND id {eq_sign} ?)",
+ (
+ self.last_order_by_value,
+ self.last_order_by_value,
+ self.last_id,
+ ),
+ )
+
+ def order_by_clause(self) -> str:
+ """Return the order by clause for the query."""
+
+ return f"{self.order_by_column} {self.order_by_direction}, id {self.order_by_direction}"
+
+
+class PaginatedQuery(Query, Sort):
+ # Number of items to return per page.
+ n_items: int
+
+ # Current position in the query.
+ cursor: Cursor
+
+ _sub_query: tuple[Query, Sort] | None
+
+ table: Literal["albums", "items"]
+
+ def __init__(
+ self,
+ cursor: Cursor,
+ sub_query: tuple[Query, Sort],
+ n_items=50,
+ table: Literal["albums", "items"] = "albums",
+ ) -> None:
+ super().__init__()
+ self.n_items = n_items
+ self.cursor = cursor
+ self._sub_query = sub_query
+ self.table = table
+
+ def clause(self) -> tuple[str | None, Sequence[Any]]:
+ """Return the SQL clause and values for the query."""
+
+ if self._sub_query:
+ # If there is a sub-query, use it to filter the results
+ cs, vs = self._sub_query[0].clause()
+ else:
+ cs = "1=1" # No sub-query, match all
+ vs = ()
+
+ cc, cv = self.cursor.causes()
+ return f"({cs}) AND ({cc})", list(vs) + list(cv)
+
+ def order_clause(self) -> str:
+ """Order by added date and id descending."""
+ sub_sort = self._sub_query[1] if self._sub_query else None
+ cursor_order = self.cursor.order_by_clause()
+ if sub_sort:
+ return f"{cursor_order} LIMIT {self.n_items}"
+ return f"{cursor_order} LIMIT {self.n_items}"
+
+ def match(self, obj: Model) -> bool: # type: ignore
+ return isinstance(obj, Item) or isinstance(obj, Album)
+
+ def total(self, lib: Library) -> int:
+ """Return the total number of items in the query."""
+
+ if self._sub_query:
+ # If there is a sub-query, use it to filter the results
+ cs, vs = self._sub_query[0].clause()
+ else:
+ cs = "1=1" # No sub-query, match all
+ vs = ()
+
+ with g.lib.transaction() as tx:
+ count = tx.query(f"SELECT COUNT(*) FROM {self.table} WHERE {cs}", vs)[0][0]
+ return count
+
+
+# -------------------- Helper for formatting beets models -------------------- #
+
+
+class ItemResponseMinimal(TypedDict):
+ """Type definition for the minimal response for item."""
+
+ # Unique identifier for the item in the beets library
+ id: int
+ # Name of the item
+ name: str
+ # Full path to the item on disk
+ path: str
+ # Primary artist for the item
+ artist: str
+ # Year the item was published
+ year: int
+
+ # Name, id and the primary artist
+ # for the associated album
+ album: str
+ albumartist: str
+ album_id: int
+
+ # ISRC code for the item
+ isrc: NotRequired[str]
+
+ size: int
+
+
+class ItemResponse(ItemResponseMinimal):
+ """Type definition for the full item response.
+
+ Might not be 100% accurate as plugins may add additional fields. We
+ atleast type all field that are used in the frontend.
+ """
+
+ # The genre of the item, if multiple genres are present they are
+ # separated by a semicolon (;)
+ genre: str
+
+ # The label in which the item was published
+ label: str
+
+ # Technical details about the item
+ samplerate: int
+ bitrate: int
+ bpm: int
+ bitdepth: int
+ channels: int
+ format: str
+ encoder_info: str
+ encoder_settings: str
+ initial_key: str
+ length: float
+
+ # Album specifics
+ track: int
+ tracktotal: int
+
+ # Library specific
+ added: float
+
+ # Catalog number
+ catalognum: str
+
+ # The source of the item, e.g. CD, Vinyl, Digital
+ sources: list[ItemSource]
+
+
+class ItemSource(TypedDict):
+ source: str
+ track_id: str
+ album_id: NotRequired[str]
+ artist_id: NotRequired[str]
+
+ extra: NotRequired[dict[str, str | list[str]]]
+
+
+source_prefixes = ["mb", "spotify", "tidal", "discogs"]
+
+
+def _repr_Item(item: Item | None, minimal=False) -> ItemResponse | ItemResponseMinimal:
+ if not item:
+ raise NotFoundException("Item not found")
+
+ out: dict[str, Any] = dict()
+
+ if minimal:
+ keys = [
+ "id",
+ "name",
+ "artist",
+ "albumartist",
+ "album",
+ "album_id",
+ "year",
+ "isrc",
+ ]
+ else:
+ # Use all keys
+ keys = item.keys(True) + ["name"]
+
+ # Check data source prefixes:
+ # plugins such as spotify, tidal, discogs add a prefix to the id,
+ # we want to split this prefix from the id and add them to a list of
+ # sources
+ sources: list[ItemSource] = list()
+ for prefix in source_prefixes:
+ f_keys = list(filter(lambda k: k.startswith(f"{prefix}_"), keys))
+
+ track_id, track_id_key = __get_id(item, prefix, "track")
+ if not track_id:
+ continue
+ source = ItemSource(source=prefix, track_id=track_id)
+
+ album_id, album_id_key = __get_id(item, prefix, "album")
+ if album_id:
+ source["album_id"] = album_id
+
+ artist_id, artist_id_key = __get_id(item, prefix, "artist")
+ if artist_id:
+ source["artist_id"] = artist_id
+
+ keys_extra = [
+ k
+ for k in f_keys
+ if k not in [track_id_key, album_id_key, artist_id_key]
+ ]
+ extras = {}
+ for k in keys_extra:
+ if __is_empty(item[k]):
+ continue
+ extras[__normalize_id_key(prefix, k)] = item[k]
+
+ if len(extras) > 0:
+ source["extra"] = extras
+
+ sources.append(source)
+ keys = [k for k in keys if k not in f_keys]
+
+ # additionally the mb_id fields may be filled with the same id
+ # as the any other data source if mb is disabled, this is done
+ # by beets to allow easier lookup
+ mb_source = next(filter(lambda s: s["source"] == "mb", sources), None)
+ if mb_source and len(sources) > 1:
+ for source in sources:
+ if source["source"] == "mb":
+ continue
+
+ if source["track_id"] == mb_source["track_id"]:
+ # Update source with other unset mb fields
+ # no idea why this happens but e.g. albumartist_id set for mb
+ # but not for spotify even tho the mb_albumartistid is a spotify
+ # id
+ for k, v in mb_source.items():
+ if k not in source:
+ # Fixme: Typing is a bit cursed here
+ source[k] = v # type: ignore
+
+ sources = list(filter(lambda s: s["source"] != "mb", sources))
+ break
+
+ out["sources"] = sources
+
+ for key in keys:
+ try:
+ if key == "name":
+ out[key] = item.title
+ else:
+ out[key] = item[key]
+ except AttributeError:
+ # Flex fields registered in config but not stored on this item
+ continue
+
+ # Format path
+ if key == "path":
+ out[key] = beets_util.displayable_path(out[key])
+
+ # Decode bytes
+ b = out[key]
+ if isinstance(b, bytes):
+ out[key] = base64.b64encode(b).decode("ascii")
+
+ # Remove empty values
+ if __is_empty(out[key]):
+ del out[key]
+
+ # Get the size (in bytes) of the backing file. This is useful
+ # for the Tomahawk resolver API.
+ try:
+ out["size"] = os.path.getsize(beets_util.syspath(path=item.path))
+ except OSError:
+ out["size"] = 0
+
+ return cast(ItemResponse | ItemResponseMinimal, out)
+
+
+class AlbumResponseMinimal(TypedDict):
+ """Type definition for the minimal response for album."""
+
+ # Unique identifier for the album in the beets library
+ id: int
+ # Name of the album
+ name: str
+ # Primary artist for the album
+ albumartist: str
+ # Year the album was published
+ year: int
+ # Date the album was added to the library
+ added: datetime.datetime
+
+
+class AlbumResponseMinimalExpanded(AlbumResponseMinimal):
+ items: list[ItemResponseMinimal]
+
+ gui_import_id: NotRequired[str]
+ gui_import_date: NotRequired[str]
+
+ # Not sure if these are always set
+ albumtype: NotRequired[str]
+
+
+class AlbumResponse(AlbumResponseMinimal):
+ """Type definition for the full album response.
+
+ Might not be 100% accurate as plugins may add additional fields. We
+ atleast type all field that are used in the frontend.
+ """
+
+ # The genre of the album, if multiple genres are present they are
+ # separated by a semicolon (;)
+ genre: str
+
+ # The label in which the album was published
+ label: str
+
+ # The data source of the album metadata
+ sources: list[AlbumSource]
+
+
+class AlbumResponseExpanded(AlbumResponse):
+ items: list[ItemResponse]
+
+ gui_import_id: NotRequired[str]
+ gui_import_date: NotRequired[str]
+
+ # Not sure if these are always set
+ albumtype: NotRequired[str]
+
+
+class AlbumSource(TypedDict):
+ source: str
+ album_id: str
+ artist_id: NotRequired[str]
+
+ extra: NotRequired[dict[str, str]]
+
+
+def _rep_Album(
+ album: Album, expand=False, minimal=False
+) -> AlbumResponse | AlbumResponseMinimal:
+ """Get a flat -- i.e., JSON-ish -- representation of a beets Item/Album object.
+
+ For Albums, `expand` dictates whether tracks are
+ included.
+ """
+
+ out: dict[str, Any] = dict()
+
+ if minimal:
+ keys = ["id", "name", "albumartist", "year", "added"]
+ else:
+ # Use all keys
+ keys = album.keys() + ["name"]
+
+ # Parse sources
+ out["sources"] = list()
+ for prefix in source_prefixes:
+ f_keys = list(filter(lambda k: k.startswith(f"{prefix}_"), keys))
+
+ album_id, album_id_key = __get_id(album, prefix, "album")
+ if not album_id:
+ continue
+ source = AlbumSource(source=prefix, album_id=album_id)
+
+ artist_id, artist_id_key = __get_id(album, prefix, "artist")
+ if artist_id:
+ source["artist_id"] = artist_id
+
+ keys_extra = [k for k in f_keys if k not in [album_id_key, artist_id_key]]
+ extras = {}
+ for k in keys_extra:
+ if __is_empty(album[k]):
+ continue
+ extras[__normalize_id_key(prefix, k)] = album[k]
+
+ if len(extras) > 0:
+ source["extra"] = extras
+
+ out["sources"].append(source)
+ keys = [k for k in keys if k not in f_keys]
+
+ # The mb source might be duplicated in other sources, e.g. spotify,
+ # We delete the mb source if it is a duplicate of another source.
+ mb_source = next(filter(lambda s: s["source"] == "mb", out["sources"]), None)
+ if mb_source and len(out["sources"]) > 1:
+ for source in out["sources"]:
+ if source["source"] == "mb":
+ continue
+
+ if source["album_id"] == mb_source["album_id"]:
+ # delete mb source
+ out["sources"] = list(
+ filter(lambda s: s["source"] != "mb", out["sources"])
+ )
+
+ for key in keys:
+ try:
+ if key == "name":
+ out[key] = album.album
+ else:
+ out[key] = album[key]
+ except AttributeError:
+ # Flex fields registered in config but not stored on this album
+ continue
+
+ # Format path
+ if key == "path":
+ out[key] = beets_util.displayable_path(out[key])
+
+ # Decode bytes
+ if isinstance(out[key], bytes):
+ out[key] = base64.b64encode(out[key]).decode("ascii")
+
+ # Remove empty values
+ if __is_empty(out[key]):
+ del out[key]
+
+ if key == "added":
+ # Convert to datetime
+ out[key] = datetime.datetime.fromtimestamp(out[key])
+
+ if expand:
+ out["items"] = [_repr_Item(item, minimal) for item in album.items()]
+
+ return cast(AlbumResponse | AlbumResponseMinimal, out)
+
+
+def _rep(entity: Item | Album | None, expand=False, minimal=False):
+ """Get a flat -- i.e., JSON-ish -- representation of a beets Item/Album object.
+
+ For Albums, `expand` dictates whether tracks are
+ included.
+ """
+
+ if not entity:
+ raise NotFoundException("Entity not found")
+
+ if isinstance(entity, Item):
+ return _repr_Item(entity, minimal)
+ elif isinstance(entity, Album):
+ return _rep_Album(entity, expand, minimal)
+ else:
+ raise ValueError(f"Unknown entity type: {type(entity)}")
+
+
+def __is_empty(value: str | None | list[Any], zero_empty: bool = True) -> bool:
+ """Check if empty value."""
+ if value is None:
+ return True
+ if value == "":
+ return True
+ if isinstance(value, str) and value.isspace():
+ return True
+ if isinstance(value, list) and len(value) == 0:
+ return True
+ if zero_empty and isinstance(value, int) and value == 0:
+ return True
+
+ return False
+
+
+def __get_id(
+ item: Item | Album,
+ source: str,
+ t: str,
+) -> tuple[str | None, str | None]:
+ """Get the id of a source.
+
+ Resolve inconsistencies in the beets library where the id is stored in
+ different fields.
+ """
+ s1 = item.get(f"{source}_{t}_id", None)
+ if s1:
+ return s1, f"{source}_{t}_id"
+
+ s2 = item.get(f"{source}_{t}id", None)
+ if s2:
+ return s2, f"{source}_{t}id"
+
+ return None, None
+
+
+def __normalize_id_key(prefix: str, id: str):
+ """Normalize the id key.
+
+ Inserts an underscore before the "id" or "ids" suffix.
+ Also removes the prefix.
+ """
+ return id.replace("id", "_id").replace(prefix + "_", "")
diff --git a/backend/beets_flask/server/routes/library/stats.py b/backend/beets_flask/server/routes/library/stats.py
index ea53ccce..693affaf 100644
--- a/backend/beets_flask/server/routes/library/stats.py
+++ b/backend/beets_flask/server/routes/library/stats.py
@@ -1,65 +1,70 @@
-from pathlib import Path
-from typing import TYPE_CHECKING, TypedDict, cast
-
-from quart import Blueprint, g, jsonify
-
-from beets_flask.config import get_config
-from beets_flask.disk import dir_size
-
-if TYPE_CHECKING:
- # For type hinting the global g object
- from . import g
-
-stats_bp = Blueprint("stats", __name__)
-
-__all__ = ["stats_bp"]
-
-
-class LibraryStats(TypedDict):
- libraryPath: str
- items: int # Num Tracks and stuff / num Files
- albums: int # Num Albums
- artists: int # Num Artists
- genres: int # Num Genres
- labels: int # Num Labels
-
- size: int # bytes of the library folder
- lastItemAdded: int | None # UTC timestamp
- lastItemModified: int | None # UTC timestamp
- runtime: int # seconds
-
-
-@stats_bp.route("/stats", methods=["GET"])
-async def stats():
- """Get library statistics."""
-
- config = get_config()
-
- with g.lib.transaction() as tx:
- album_stats = tx.query(
- "SELECT COUNT(*), COUNT(DISTINCT genre), COUNT(DISTINCT label), COUNT(DISTINCT albumartist) FROM albums"
- )
- items_stats = tx.query(
- "SELECT COUNT(*), MAX(added), MAX(mtime), SUM(length) FROM items"
- )
-
- lib_path = cast(str, config["directory"].get(str))
-
- ret: LibraryStats = {
- "libraryPath": str(config["directory"].as_str()),
- "items": items_stats[0][0],
- "albums": album_stats[0][0],
- "artists": album_stats[0][3],
- "genres": album_stats[0][1],
- "labels": album_stats[0][2],
- "size": dir_size(Path(lib_path)),
- "lastItemAdded": (
- round(items_stats[0][1] * 1000) if items_stats[0][1] is not None else None
- ),
- "lastItemModified": (
- round(items_stats[0][2] * 1000) if items_stats[0][2] is not None else None
- ),
- "runtime": items_stats[0][3] or 0,
- }
-
- return jsonify(ret)
+from pathlib import Path
+from typing import TYPE_CHECKING, TypedDict, cast
+
+from quart import Blueprint, g, jsonify
+
+from beets_flask.config import get_config
+from beets_flask.disk import dir_size, get_cached_dir_stats
+
+if TYPE_CHECKING:
+ # For type hinting the global g object
+ from . import g
+
+stats_bp = Blueprint("stats", __name__)
+
+__all__ = ["stats_bp"]
+
+
+class LibraryStats(TypedDict):
+ libraryPath: str
+ items: int # Num Tracks and stuff / num Files
+ albums: int # Num Albums
+ artists: int # Num Artists
+ genres: int # Num Genres
+ labels: int # Num Labels
+
+ size: int # bytes of the library folder
+ lastItemAdded: int | None # UTC timestamp
+ lastItemModified: int | None # UTC timestamp
+ runtime: int # seconds
+
+
+@stats_bp.route("/stats", methods=["GET"])
+async def stats():
+ """Get library statistics."""
+
+ config = get_config()
+
+ with g.lib.transaction() as tx:
+ album_stats = tx.query(
+ "SELECT COUNT(*), COUNT(DISTINCT genre), COUNT(DISTINCT label), COUNT(DISTINCT albumartist) FROM albums"
+ )
+ items_stats = tx.query(
+ "SELECT COUNT(*), MAX(added), MAX(mtime), SUM(length) FROM items"
+ )
+
+ lib_path = cast(str, config["directory"].get(str))
+
+ ret: LibraryStats = {
+ "libraryPath": str(config["directory"].as_str()),
+ "items": items_stats[0][0],
+ "albums": album_stats[0][0],
+ "artists": album_stats[0][3],
+ "genres": album_stats[0][1],
+ "labels": album_stats[0][2],
+ "size": (
+ cached.size_bytes
+ if (cached := get_cached_dir_stats(Path(lib_path))) is not None
+ and cached.size_bytes is not None
+ else dir_size(Path(lib_path))
+ ),
+ "lastItemAdded": (
+ round(items_stats[0][1] * 1000) if items_stats[0][1] is not None else None
+ ),
+ "lastItemModified": (
+ round(items_stats[0][2] * 1000) if items_stats[0][2] is not None else None
+ ),
+ "runtime": items_stats[0][3] or 0,
+ }
+
+ return jsonify(ret)
diff --git a/backend/beets_flask/server/routes/monitor.py b/backend/beets_flask/server/routes/monitor.py
index f7831611..a1cb962c 100644
--- a/backend/beets_flask/server/routes/monitor.py
+++ b/backend/beets_flask/server/routes/monitor.py
@@ -1,108 +1,108 @@
-from quart import Blueprint
-from rq.job import Job
-from rq.worker import Worker
-
-from beets_flask.redis import queues, redis_conn
-
-monitor_bp = Blueprint("monitor", __name__, url_prefix="/monitor")
-
-
-@monitor_bp.route("/queues", methods=["GET"])
-async def get_queue_status():
- """
- Get the status of the job queues.
-
- Returns
- -------
- dict: A dictionary containing the status of each job queue.
-
- """
- # for q in queues:
- # clean_registries(q)
- # clean_worker_registry(q)
-
- ret_dict = {}
- for q in queues:
- ret_dict[q.name] = {
- "name": q.name,
- "queued": q.count,
- "queued_jobs": q.job_ids,
- "scheduled": q.scheduled_job_registry.count,
- "executing": q.started_job_registry.count,
- "finished": q.finished_job_registry.count,
- "failed": q.failed_job_registry.count,
- }
-
- return {"queues": ret_dict}
-
-
-@monitor_bp.route("/workers", methods=["GET"])
-async def get_worker_status():
- """
- Get the status of the RQ workers.
-
- Returns
- -------
- dict: A dictionary containing the status of each worker.
-
- """
- workers = Worker.all(connection=redis_conn)
-
- ret_dict = {}
- for w in workers:
- ret_dict[w.name] = {
- "name": w.name,
- "queues": w.queue_names(),
- "state": w.get_state(),
- "executed": w.successful_job_count,
- "failed": w.failed_job_count,
- }
-
- return {"workers": ret_dict}
-
-
-@monitor_bp.route("/jobs", methods=["GET"])
-async def get_job_status():
- """
- Get the status of the jobs in the job queues.
-
- Returns
- -------
- dict: A dictionary containing the status of each job in each job queue.
-
- """
- # https://python-rq.org/docs/job_registries/
- ret = []
- for q in queues:
- jobs = Job.fetch_many(
- q.started_job_registry.get_job_ids(), connection=redis_conn
- )
- for j in jobs:
- if j is None:
- continue
- ret.append(
- {
- "q_name": q.name,
- "job_id": j.id,
- "meta": j.get_meta(False),
- }
- )
-
- return ret
-
-
-@monitor_bp.route("/debugResetDb", methods=["GET"])
-async def reset_database():
- """
- Reset the sql database.
-
- Returns
- -------
- dict: A dictionary containing the status of the reset operation.
-
- """
- from beets_flask.database.setup import _reset_database
-
- _reset_database()
-
- return {"status": "success"}
+from quart import Blueprint
+from rq.job import Job
+from rq.worker import Worker
+
+from beets_flask.redis import queues, redis_conn
+
+monitor_bp = Blueprint("monitor", __name__, url_prefix="/monitor")
+
+
+@monitor_bp.route("/queues", methods=["GET"])
+async def get_queue_status():
+ """
+ Get the status of the job queues.
+
+ Returns
+ -------
+ dict: A dictionary containing the status of each job queue.
+
+ """
+ # for q in queues:
+ # clean_registries(q)
+ # clean_worker_registry(q)
+
+ ret_dict = {}
+ for q in queues:
+ ret_dict[q.name] = {
+ "name": q.name,
+ "queued": q.count,
+ "queued_jobs": q.job_ids,
+ "scheduled": q.scheduled_job_registry.count,
+ "executing": q.started_job_registry.count,
+ "finished": q.finished_job_registry.count,
+ "failed": q.failed_job_registry.count,
+ }
+
+ return {"queues": ret_dict}
+
+
+@monitor_bp.route("/workers", methods=["GET"])
+async def get_worker_status():
+ """
+ Get the status of the RQ workers.
+
+ Returns
+ -------
+ dict: A dictionary containing the status of each worker.
+
+ """
+ workers = Worker.all(connection=redis_conn)
+
+ ret_dict = {}
+ for w in workers:
+ ret_dict[w.name] = {
+ "name": w.name,
+ "queues": w.queue_names(),
+ "state": w.get_state(),
+ "executed": w.successful_job_count,
+ "failed": w.failed_job_count,
+ }
+
+ return {"workers": ret_dict}
+
+
+@monitor_bp.route("/jobs", methods=["GET"])
+async def get_job_status():
+ """
+ Get the status of the jobs in the job queues.
+
+ Returns
+ -------
+ dict: A dictionary containing the status of each job in each job queue.
+
+ """
+ # https://python-rq.org/docs/job_registries/
+ ret = []
+ for q in queues:
+ jobs = Job.fetch_many(
+ q.started_job_registry.get_job_ids(), connection=redis_conn
+ )
+ for j in jobs:
+ if j is None:
+ continue
+ ret.append(
+ {
+ "q_name": q.name,
+ "job_id": j.id,
+ "meta": j.get_meta(False),
+ }
+ )
+
+ return ret
+
+
+@monitor_bp.route("/debugResetDb", methods=["GET"])
+async def reset_database():
+ """
+ Reset the sql database.
+
+ Returns
+ -------
+ dict: A dictionary containing the status of the reset operation.
+
+ """
+ from beets_flask.database.setup import _reset_database
+
+ _reset_database()
+
+ return {"status": "success"}
diff --git a/backend/beets_flask/server/utility.py b/backend/beets_flask/server/utility.py
index 113b91d0..3921632f 100644
--- a/backend/beets_flask/server/utility.py
+++ b/backend/beets_flask/server/utility.py
@@ -1,141 +1,141 @@
-from collections.abc import Callable
-from pathlib import Path
-from typing import cast
-
-from typing_extensions import TypeVar
-
-from beets_flask.invoker.job import ExtraJobMeta
-
-from .exceptions import InvalidUsageException
-
-R = TypeVar("R")
-D = TypeVar(
- "D",
- default=None,
-)
-
-
-def pop_query_param(
- params: dict,
- key: str,
- convert_func: Callable[..., R],
- default: D | None = None,
- error_message: str | None = None,
-) -> D | R:
- """Safely retrieves and converts a query parameter from the request args.
-
- Parameters
- ----------
- params : dict
- The request args.
- key : str
- The key of the parameter to retrieve.
- default : any, optional
- The default value if the parameter is not found, defaults to None.
- convert_func : callable, optional
- A function to convert the parameter value, defaults to None. Common example, just use the type: `str`, `int` etc.
- error_message : str, optional
- The error message to raise if the conversion fails, defaults to None.
- """
- if params is None:
- return default
-
- value = params.pop(key, None)
-
- if value is None:
- return cast(D, default)
-
- try:
- value = convert_func(value)
- except (ValueError, TypeError):
- if error_message is None:
- error_message = f"Invalid parameter'{key}'"
- raise InvalidUsageException(error_message)
-
- return value
-
-
-def pop_extra_meta(params: dict, n_jobs=1) -> list[ExtraJobMeta]:
- """Extract fields that qualify as extra metadata from your request.
-
- Used for adding metadata to jobs that are not strictly required for the job to run. But
- are useful for tracking the job in the frontend.
-
- Parameters
- ----------
- params : dict
- The request args.
- """
-
- job_refs: list[str] | None = pop_query_param(
- params=params, key="job_frontend_refs", convert_func=list, default=None
- )
-
- if job_refs is None:
- return [{} for _ in range(n_jobs)]
- if not isinstance(job_refs, list):
- raise InvalidUsageException("job_frontend_refs must be a list")
- if len(job_refs) != n_jobs:
- raise InvalidUsageException(
- f"job_frontend_refs must be a list of length {n_jobs}"
- )
-
- return [ExtraJobMeta(job_frontend_ref=job_ref) for job_ref in job_refs]
-
-
-def pop_folder_params(
- params: dict,
- allow_mismatch: bool = False,
- allow_empty: bool = True,
-) -> tuple[list[str], list[Path]]:
- """Get folder hashes and paths from the request parameters.
-
- Parameters
- ----------
- params : dict
- The request args.
- allow_mismatch : bool, optional
- Allow the folder hashes and paths to have different lengths, by default False
- allow_empty : bool, optional
- Allow empty folder hashes and paths, by default False
-
- Returns
- -------
- folder_hashes : list
- folder_paths : list
- params : Any
-
- """
- folder_hashes: list[str] = pop_query_param(
- params, "folder_hashes", list, default=[]
- )
- folder_paths: list[Path] = pop_query_param(
- params, "folder_paths", lambda x: [Path(p) for p in x], default=[]
- )
-
- if not allow_mismatch and len(folder_hashes) != len(folder_paths):
- raise InvalidUsageException(
- "folder_hashes and folder_paths must be of the same length"
- )
-
- if not allow_empty and ((len(folder_hashes) + len(folder_paths)) == 0):
- raise InvalidUsageException("folder_hashes and folder_paths cannot be empty")
-
- return folder_hashes, folder_paths
-
-
-def pop_paths_param(params: dict, key: str, default: D | None = None) -> list[Path] | D:
- """Safely retrieves a path parameter from the request args."""
-
- def ensure_list_of_path(obj) -> list[Path]:
- if not isinstance(obj, list):
- return [Path(obj)]
- return [Path(o) for o in obj]
-
- return pop_query_param(
- params=params,
- key=key,
- convert_func=ensure_list_of_path,
- default=default,
- error_message=f"Invalid parameter '{key}'",
- )
+from collections.abc import Callable
+from pathlib import Path
+from typing import cast
+
+from typing_extensions import TypeVar
+
+from beets_flask.invoker.job import ExtraJobMeta
+
+from .exceptions import InvalidUsageException
+
+R = TypeVar("R")
+D = TypeVar(
+ "D",
+ default=None,
+)
+
+
+def pop_query_param(
+ params: dict,
+ key: str,
+ convert_func: Callable[..., R],
+ default: D | None = None,
+ error_message: str | None = None,
+) -> D | R:
+ """Safely retrieves and converts a query parameter from the request args.
+
+ Parameters
+ ----------
+ params : dict
+ The request args.
+ key : str
+ The key of the parameter to retrieve.
+ default : any, optional
+ The default value if the parameter is not found, defaults to None.
+ convert_func : callable, optional
+ A function to convert the parameter value, defaults to None. Common example, just use the type: `str`, `int` etc.
+ error_message : str, optional
+ The error message to raise if the conversion fails, defaults to None.
+ """
+ if params is None:
+ return default
+
+ value = params.pop(key, None)
+
+ if value is None:
+ return cast(D, default)
+
+ try:
+ value = convert_func(value)
+ except (ValueError, TypeError):
+ if error_message is None:
+ error_message = f"Invalid parameter'{key}'"
+ raise InvalidUsageException(error_message)
+
+ return value
+
+
+def pop_extra_meta(params: dict, n_jobs=1) -> list[ExtraJobMeta]:
+ """Extract fields that qualify as extra metadata from your request.
+
+ Used for adding metadata to jobs that are not strictly required for the job to run. But
+ are useful for tracking the job in the frontend.
+
+ Parameters
+ ----------
+ params : dict
+ The request args.
+ """
+
+ job_refs: list[str] | None = pop_query_param(
+ params=params, key="job_frontend_refs", convert_func=list, default=None
+ )
+
+ if job_refs is None:
+ return [{} for _ in range(n_jobs)]
+ if not isinstance(job_refs, list):
+ raise InvalidUsageException("job_frontend_refs must be a list")
+ if len(job_refs) != n_jobs:
+ raise InvalidUsageException(
+ f"job_frontend_refs must be a list of length {n_jobs}"
+ )
+
+ return [ExtraJobMeta(job_frontend_ref=job_ref) for job_ref in job_refs]
+
+
+def pop_folder_params(
+ params: dict,
+ allow_mismatch: bool = False,
+ allow_empty: bool = True,
+) -> tuple[list[str], list[Path]]:
+ """Get folder hashes and paths from the request parameters.
+
+ Parameters
+ ----------
+ params : dict
+ The request args.
+ allow_mismatch : bool, optional
+ Allow the folder hashes and paths to have different lengths, by default False
+ allow_empty : bool, optional
+ Allow empty folder hashes and paths, by default False
+
+ Returns
+ -------
+ folder_hashes : list
+ folder_paths : list
+ params : Any
+
+ """
+ folder_hashes: list[str] = pop_query_param(
+ params, "folder_hashes", list, default=[]
+ )
+ folder_paths: list[Path] = pop_query_param(
+ params, "folder_paths", lambda x: [Path(p) for p in x], default=[]
+ )
+
+ if not allow_mismatch and len(folder_hashes) != len(folder_paths):
+ raise InvalidUsageException(
+ "folder_hashes and folder_paths must be of the same length"
+ )
+
+ if not allow_empty and ((len(folder_hashes) + len(folder_paths)) == 0):
+ raise InvalidUsageException("folder_hashes and folder_paths cannot be empty")
+
+ return folder_hashes, folder_paths
+
+
+def pop_paths_param(params: dict, key: str, default: D | None = None) -> list[Path] | D:
+ """Safely retrieves a path parameter from the request args."""
+
+ def ensure_list_of_path(obj) -> list[Path]:
+ if not isinstance(obj, list):
+ return [Path(obj)]
+ return [Path(o) for o in obj]
+
+ return pop_query_param(
+ params=params,
+ key=key,
+ convert_func=ensure_list_of_path,
+ default=default,
+ error_message=f"Invalid parameter '{key}'",
+ )
diff --git a/backend/beets_flask/server/websocket/__init__.py b/backend/beets_flask/server/websocket/__init__.py
index fcc63027..36a93a9c 100644
--- a/backend/beets_flask/server/websocket/__init__.py
+++ b/backend/beets_flask/server/websocket/__init__.py
@@ -1,40 +1,40 @@
-import os
-from collections.abc import Callable
-from typing import cast
-
-import socketio
-
-old_on = socketio.AsyncServer.on
-
-
-# Gets rid of the type error in the decorator
-class TypedAsyncServer(socketio.AsyncServer):
- def on(self, event: str, namespace: str | None = None) -> Callable: ... # type: ignore
-
-
-if os.environ.get("PYTEST_CURRENT_TEST", ""):
- client_manager = None
-else:
- client_manager = socketio.AsyncRedisManager("redis://")
-
-sio: TypedAsyncServer = cast(
- TypedAsyncServer,
- socketio.AsyncServer(
- async_mode="asgi",
- logger=False,
- engineio_logger=False,
- cors_allowed_origins="*",
- client_manager=client_manager,
- ),
-)
-
-
-def register_socketio(app):
- app.asgi_app = socketio.ASGIApp(sio, app.asgi_app, socketio_path="/socket.io")
-
- # Register all socketio namespaces
- from .status import register_status
- from .terminal import register_tmux
-
- register_tmux()
- register_status()
+import os
+from collections.abc import Callable
+from typing import cast
+
+import socketio
+
+old_on = socketio.AsyncServer.on
+
+
+# Gets rid of the type error in the decorator
+class TypedAsyncServer(socketio.AsyncServer):
+ def on(self, event: str, namespace: str | None = None) -> Callable: ... # type: ignore
+
+
+if os.environ.get("PYTEST_CURRENT_TEST", ""):
+ client_manager = None
+else:
+ client_manager = socketio.AsyncRedisManager("redis://")
+
+sio: TypedAsyncServer = cast(
+ TypedAsyncServer,
+ socketio.AsyncServer(
+ async_mode="asgi",
+ logger=False,
+ engineio_logger=False,
+ cors_allowed_origins="*",
+ client_manager=client_manager,
+ ),
+)
+
+
+def register_socketio(app):
+ app.asgi_app = socketio.ASGIApp(sio, app.asgi_app, socketio_path="/socket.io")
+
+ # Register all socketio namespaces
+ from .status import register_status
+ from .terminal import register_tmux
+
+ register_tmux()
+ register_status()
diff --git a/backend/beets_flask/server/websocket/errors.py b/backend/beets_flask/server/websocket/errors.py
index dab1eb06..41d50aab 100644
--- a/backend/beets_flask/server/websocket/errors.py
+++ b/backend/beets_flask/server/websocket/errors.py
@@ -1,87 +1,87 @@
-"""Similar to the routes/errors.py file, this file contains error handling logic for the websocket routes.
-
-We parse all exceptions to a common format and return them to the client for handling.
-"""
-
-from __future__ import annotations
-
-import functools
-from collections.abc import Awaitable, Callable
-from typing import (
- NotRequired,
- ParamSpec,
- TypedDict,
- TypeVar,
-)
-
-from beets_flask import log
-
-
-class WebSocketErrorDict(TypedDict):
- """Common error format for websocket routes."""
-
- error: str
- message: str
- description: NotRequired[str]
-
-
-Params = ParamSpec("Params")
-ReturnType = TypeVar("ReturnType")
-
-
-def sio_catch_exception(
- func: Callable[Params, Awaitable[ReturnType]],
-) -> Callable[Params, Awaitable[ReturnType | WebSocketErrorDict]]:
- """Parse exceptions to a common format for websocket routes.
-
- Returned functions may than return a
- WebSocketErrorDict if an exception is caught. This should
- be handled on the fronten.
-
- Usage
- -----
- ```python
- @sio.on("event_name", namespace="/namespace")
- @sio_catch_expection
- def event_name(sid, *args, **kwargs):
- raise Exception("Exception message")
- """
-
- @functools.wraps(func)
- async def wrapper(*args, **kwargs) -> ReturnType | WebSocketErrorDict:
- try:
- # Get number of arguments as socketio will not infer them correctly
- n_args = func.__code__.co_argcount
- result = await func(*args[:n_args], **kwargs)
- return result
- except Exception as e:
- return _error_parser(e)
-
- return wrapper
-
-
-def _error_parser(e: Exception) -> WebSocketErrorDict:
- # We may add some exception handling here if
- # we want to handle some exceptions differently
- log.exception(f"Unhandled websocket error: {e}")
- d = WebSocketErrorDict(
- error=e.__class__.__name__,
- message=str(e),
- )
- return d
-
-
-__all__ = ["sio_catch_exception", "WebSocketErrorDict"]
-
-
-"""Allow to throw the errors in a testing
-environment. This is useful for testing
-the error handling on the frontend side.
-"""
-from . import sio
-
-
-@sio.on("test_generic_exc", namespace="/test")
-@sio_catch_exception
-def test_generic_exc(sid):
- raise Exception("Exception message")
+"""Similar to the routes/errors.py file, this file contains error handling logic for the websocket routes.
+
+We parse all exceptions to a common format and return them to the client for handling.
+"""
+
+from __future__ import annotations
+
+import functools
+from collections.abc import Awaitable, Callable
+from typing import (
+ NotRequired,
+ ParamSpec,
+ TypedDict,
+ TypeVar,
+)
+
+from beets_flask import log
+
+
+class WebSocketErrorDict(TypedDict):
+ """Common error format for websocket routes."""
+
+ error: str
+ message: str
+ description: NotRequired[str]
+
+
+Params = ParamSpec("Params")
+ReturnType = TypeVar("ReturnType")
+
+
+def sio_catch_exception(
+ func: Callable[Params, Awaitable[ReturnType]],
+) -> Callable[Params, Awaitable[ReturnType | WebSocketErrorDict]]:
+ """Parse exceptions to a common format for websocket routes.
+
+ Returned functions may than return a
+ WebSocketErrorDict if an exception is caught. This should
+ be handled on the fronten.
+
+ Usage
+ -----
+ ```python
+ @sio.on("event_name", namespace="/namespace")
+ @sio_catch_expection
+ def event_name(sid, *args, **kwargs):
+ raise Exception("Exception message")
+ """
+
+ @functools.wraps(func)
+ async def wrapper(*args, **kwargs) -> ReturnType | WebSocketErrorDict:
+ try:
+ # Get number of arguments as socketio will not infer them correctly
+ n_args = func.__code__.co_argcount
+ result = await func(*args[:n_args], **kwargs)
+ return result
+ except Exception as e:
+ return _error_parser(e)
+
+ return wrapper
+
+
+def _error_parser(e: Exception) -> WebSocketErrorDict:
+ # We may add some exception handling here if
+ # we want to handle some exceptions differently
+ log.exception(f"Unhandled websocket error: {e}")
+ d = WebSocketErrorDict(
+ error=e.__class__.__name__,
+ message=str(e),
+ )
+ return d
+
+
+__all__ = ["sio_catch_exception", "WebSocketErrorDict"]
+
+
+"""Allow to throw the errors in a testing
+environment. This is useful for testing
+the error handling on the frontend side.
+"""
+from . import sio
+
+
+@sio.on("test_generic_exc", namespace="/test")
+@sio_catch_exception
+def test_generic_exc(sid):
+ raise Exception("Exception message")
diff --git a/backend/beets_flask/server/websocket/status.py b/backend/beets_flask/server/websocket/status.py
index bdeed082..d64e3308 100644
--- a/backend/beets_flask/server/websocket/status.py
+++ b/backend/beets_flask/server/websocket/status.py
@@ -1,222 +1,222 @@
-"""Allows the client to listen to status updates from the server.
-
-Status updates are mainly send after a successful import or when
-a preview is finished.
-"""
-
-from __future__ import annotations
-
-from collections.abc import Awaitable, Callable
-from dataclasses import dataclass
-from functools import wraps
-from typing import Concatenate, Literal, ParamSpec, TypeVar
-
-import socketio
-from quart import json
-
-from beets_flask.database import db_session_factory
-from beets_flask.database.models.states import FolderInDb
-from beets_flask.disk import clear_cache
-from beets_flask.importer.progress import FolderStatus
-from beets_flask.invoker.job import JobMeta
-from beets_flask.logger import log
-from beets_flask.server.exceptions import (
- InvalidUsageException,
- SerializedException,
- to_serialized_exception,
-)
-
-from . import sio
-from .errors import sio_catch_exception
-
-
-@dataclass
-class JobStatusUpdate:
- message: str
- num_jobs: int
- job_metas: list[JobMeta]
- exc: SerializedException | None = None
- event: Literal["job_status_update"] = "job_status_update"
-
-
-@dataclass
-class FolderStatusUpdate:
- path: str
- hash: str
- status: FolderStatus
- exc: SerializedException | None = None
- event: Literal["folder_status_update"] = "folder_status_update"
-
-
-@dataclass
-class FileSystemUpdate:
- exc: SerializedException | None = None
- event: Literal["file_system_update"] = "file_system_update"
-
-
-namespace = "/status"
-
-
-@sio.on("connect", namespace=namespace)
-@sio_catch_exception
-async def connect(sid, *args):
- """Log connection."""
- log.debug(f"StatusSocket sid {sid} connected")
-
-
-# ---------------------------- Emit to all clients --------------------------- #
-
-
-@sio.on("folder_status_update", namespace=namespace)
-@sio_catch_exception
-async def folder_update(sid, data):
- log.debug(f"folder_status_update: {data}")
- await sio.emit("folder_status_update", data, namespace=namespace)
-
-
-@sio.on("job_status_update", namespace=namespace)
-@sio_catch_exception
-async def job_update(sid, data):
- log.debug(f"job_status_update: {data}")
- await sio.emit("job_status_update", data, namespace=namespace)
-
-
-@sio.on("file_system_update", namespace=namespace)
-@sio_catch_exception
-async def fs_update(sid, data):
- log.debug(f"file_system_update: {data}")
- clear_cache()
- await sio.emit("file_system_update", data, namespace=namespace)
-
-
-# ------------------------------------- * ------------------------------------ #
-
-
-@sio.on("*", namespace=namespace)
-@sio_catch_exception
-async def any_event(event, sid, data):
- """Debug unhandled events."""
- log.debug(f"StatusSocket sid {sid} unhandled event {event} with data {data}")
-
-
-async def send_status_update(
- status: FolderStatusUpdate | JobStatusUpdate | FileSystemUpdate,
-):
- """Send a status update to propagate to all clients.
-
- Allows to pass an exception as part of the status update.
- This is used when a preview fails, i.e. FolderStatus.FAILED
- """
-
- # We use a simple client here as this code may be called from
- # redis workers which do not have access to the sio instance.
- # See /status/update sio endpoint above for how this is handled
- # on the server.
- # By providing the json module via quart, we reuse our custom json encoder.
- client = socketio.AsyncClient(json=json)
- # FIXME: Static URL is difficult to maintain and testing does not work
- # with this setup. We need to find a way to make this dynamic.
- await client.connect("ws://127.0.0.1:5001", namespaces=[namespace])
-
- # We need to use call (instead of emit) as otherwise the event is not emitted
- # if we close the client immediately after connecting
- await client.call(
- status.event,
- status,
- namespace=namespace,
- timeout=5,
- )
- await client.disconnect()
-
-
-async def trigger_clear_cache():
- """Trigger a cache clear via the status socket."""
- # This is used to clear the cache when a folder is deleted.
- # We use the FileSystemUpdate event to trigger this.
- # This clears the cache in all workers and clients
- clear_cache()
- await send_status_update(FileSystemUpdate())
-
-
-R = TypeVar("R") # Return
-P = ParamSpec("P") # Parameters
-
-
-def emit_folder_status(
- before: FolderStatus | None = None, after: FolderStatus | None = None
-) -> Callable[
- [Callable[Concatenate[str, str, P], Awaitable[R]]],
- Callable[Concatenate[str, str | None, P], Awaitable[R]],
-]:
- """Decorator to propagate status updates to clients.
-
- Parameters
- ----------
- before: FolderStatus, optional
- The status before the function is called. If none is given, no status update is sent.
- after: FolderStatus, optional
- The status after the function is called. If none is given, no status update is sent.
- """
-
- def decorator(
- f: Callable[Concatenate[str, str, P], Awaitable[R]],
- ) -> Callable[Concatenate[str, str | None, P], Awaitable[R]]:
- @wraps(f)
- async def wrapper(hash: str, path: str | None, *args, **kwargs) -> R:
- # if only a hash is given and no path, we retrieve the path from the db
- if path is None:
- with db_session_factory() as db_session:
- f_on_disk = FolderInDb.get_by(
- FolderInDb.id == hash, session=db_session
- )
- if f_on_disk is None:
- raise InvalidUsageException(
- f"If only hash is given, it must be in the db."
- )
- path = f_on_disk.full_path
-
- # FIXME: In theory we could keep the socket client open here
- if before is not None:
- await send_status_update(
- FolderStatusUpdate(
- hash=hash,
- path=path,
- status=before,
- )
- )
-
- try:
- ret = await f(hash, path, *args, **kwargs)
- except Exception as e:
- # if the function fails, we want to send a failed status update
- # and raise the exception again.
- await send_status_update(
- FolderStatusUpdate(
- hash=hash,
- path=path,
- status=FolderStatus.FAILED,
- exc=to_serialized_exception(e),
- )
- )
-
- raise e
-
- if after is not None:
- await send_status_update(
- FolderStatusUpdate(
- hash=hash,
- path=path,
- status=after,
- )
- )
-
- return ret
-
- return wrapper
-
- return decorator
-
-
-def register_status():
- # we need this to at least allow loading the module at the right time
- pass
+"""Allows the client to listen to status updates from the server.
+
+Status updates are mainly send after a successful import or when
+a preview is finished.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+from functools import wraps
+from typing import Concatenate, Literal, ParamSpec, TypeVar
+
+import socketio
+from quart import json
+
+from beets_flask.database import db_session_factory
+from beets_flask.database.models.states import FolderInDb
+from beets_flask.disk import clear_cache
+from beets_flask.importer.progress import FolderStatus
+from beets_flask.invoker.job import JobMeta
+from beets_flask.logger import log
+from beets_flask.server.exceptions import (
+ InvalidUsageException,
+ SerializedException,
+ to_serialized_exception,
+)
+
+from . import sio
+from .errors import sio_catch_exception
+
+
+@dataclass
+class JobStatusUpdate:
+ message: str
+ num_jobs: int
+ job_metas: list[JobMeta]
+ exc: SerializedException | None = None
+ event: Literal["job_status_update"] = "job_status_update"
+
+
+@dataclass
+class FolderStatusUpdate:
+ path: str
+ hash: str
+ status: FolderStatus
+ exc: SerializedException | None = None
+ event: Literal["folder_status_update"] = "folder_status_update"
+
+
+@dataclass
+class FileSystemUpdate:
+ exc: SerializedException | None = None
+ event: Literal["file_system_update"] = "file_system_update"
+
+
+namespace = "/status"
+
+
+@sio.on("connect", namespace=namespace)
+@sio_catch_exception
+async def connect(sid, *args):
+ """Log connection."""
+ log.debug(f"StatusSocket sid {sid} connected")
+
+
+# ---------------------------- Emit to all clients --------------------------- #
+
+
+@sio.on("folder_status_update", namespace=namespace)
+@sio_catch_exception
+async def folder_update(sid, data):
+ log.debug(f"folder_status_update: {data}")
+ await sio.emit("folder_status_update", data, namespace=namespace)
+
+
+@sio.on("job_status_update", namespace=namespace)
+@sio_catch_exception
+async def job_update(sid, data):
+ log.debug(f"job_status_update: {data}")
+ await sio.emit("job_status_update", data, namespace=namespace)
+
+
+@sio.on("file_system_update", namespace=namespace)
+@sio_catch_exception
+async def fs_update(sid, data):
+ log.debug(f"file_system_update: {data}")
+ clear_cache()
+ await sio.emit("file_system_update", data, namespace=namespace)
+
+
+# ------------------------------------- * ------------------------------------ #
+
+
+@sio.on("*", namespace=namespace)
+@sio_catch_exception
+async def any_event(event, sid, data):
+ """Debug unhandled events."""
+ log.debug(f"StatusSocket sid {sid} unhandled event {event} with data {data}")
+
+
+async def send_status_update(
+ status: FolderStatusUpdate | JobStatusUpdate | FileSystemUpdate,
+):
+ """Send a status update to propagate to all clients.
+
+ Allows to pass an exception as part of the status update.
+ This is used when a preview fails, i.e. FolderStatus.FAILED
+ """
+
+ # We use a simple client here as this code may be called from
+ # redis workers which do not have access to the sio instance.
+ # See /status/update sio endpoint above for how this is handled
+ # on the server.
+ # By providing the json module via quart, we reuse our custom json encoder.
+ client = socketio.AsyncClient(json=json)
+ # FIXME: Static URL is difficult to maintain and testing does not work
+ # with this setup. We need to find a way to make this dynamic.
+ await client.connect("ws://127.0.0.1:5001", namespaces=[namespace])
+
+ # We need to use call (instead of emit) as otherwise the event is not emitted
+ # if we close the client immediately after connecting
+ await client.call(
+ status.event,
+ status,
+ namespace=namespace,
+ timeout=5,
+ )
+ await client.disconnect()
+
+
+async def trigger_clear_cache():
+ """Trigger a cache clear via the status socket."""
+ # This is used to clear the cache when a folder is deleted.
+ # We use the FileSystemUpdate event to trigger this.
+ # This clears the cache in all workers and clients
+ clear_cache()
+ await send_status_update(FileSystemUpdate())
+
+
+R = TypeVar("R") # Return
+P = ParamSpec("P") # Parameters
+
+
+def emit_folder_status(
+ before: FolderStatus | None = None, after: FolderStatus | None = None
+) -> Callable[
+ [Callable[Concatenate[str, str, P], Awaitable[R]]],
+ Callable[Concatenate[str, str | None, P], Awaitable[R]],
+]:
+ """Decorator to propagate status updates to clients.
+
+ Parameters
+ ----------
+ before: FolderStatus, optional
+ The status before the function is called. If none is given, no status update is sent.
+ after: FolderStatus, optional
+ The status after the function is called. If none is given, no status update is sent.
+ """
+
+ def decorator(
+ f: Callable[Concatenate[str, str, P], Awaitable[R]],
+ ) -> Callable[Concatenate[str, str | None, P], Awaitable[R]]:
+ @wraps(f)
+ async def wrapper(hash: str, path: str | None, *args, **kwargs) -> R:
+ # if only a hash is given and no path, we retrieve the path from the db
+ if path is None:
+ with db_session_factory() as db_session:
+ f_on_disk = FolderInDb.get_by(
+ FolderInDb.id == hash, session=db_session
+ )
+ if f_on_disk is None:
+ raise InvalidUsageException(
+ f"If only hash is given, it must be in the db."
+ )
+ path = f_on_disk.full_path
+
+ # FIXME: In theory we could keep the socket client open here
+ if before is not None:
+ await send_status_update(
+ FolderStatusUpdate(
+ hash=hash,
+ path=path,
+ status=before,
+ )
+ )
+
+ try:
+ ret = await f(hash, path, *args, **kwargs)
+ except Exception as e:
+ # if the function fails, we want to send a failed status update
+ # and raise the exception again.
+ await send_status_update(
+ FolderStatusUpdate(
+ hash=hash,
+ path=path,
+ status=FolderStatus.FAILED,
+ exc=to_serialized_exception(e),
+ )
+ )
+
+ raise e
+
+ if after is not None:
+ await send_status_update(
+ FolderStatusUpdate(
+ hash=hash,
+ path=path,
+ status=after,
+ )
+ )
+
+ return ret
+
+ return wrapper
+
+ return decorator
+
+
+def register_status():
+ # we need this to at least allow loading the module at the right time
+ pass
diff --git a/backend/beets_flask/server/websocket/terminal.py b/backend/beets_flask/server/websocket/terminal.py
index f42af0fa..43f03d73 100644
--- a/backend/beets_flask/server/websocket/terminal.py
+++ b/backend/beets_flask/server/websocket/terminal.py
@@ -1,245 +1,245 @@
-"""SocketIO for terminal emulation.
-
-Adapted from the excellent tutorial by cs01:
-https://github.com/cs01/pyxtermjs
-
-Notes on tmux:
-- To manually connect to the session used for the web:
- `docker exec -it beets-flask /usr/bin/tmux attach-session -t beets-socket-term`
-- We send the whole current pane (window) content to the client, and resend when it
-changes.
-- **Currently, trailing whitespaces get stripped**. I did not manage to get a real live-representation of the input line, including trailing whitespaces. `pane.capture_pane()` uses under the hood: `pane.cmd(*["capture-pane", "-p"]).stdout`
-and we might want to play around with -T -N -J -e
-https://www.man7.org/linux/man-pages/man1/tmux.1.html
-- If we want to prepare commands client side before sending the finished command, cf.:
-https://stackoverflow.com/questions/44447473/how-to-make-xterm-js-accept-input
-
-# TODO: Typing for socket events
-
-"""
-
-from __future__ import annotations
-
-import asyncio
-
-import libtmux
-from libtmux import Pane, Session, Window
-from libtmux.exc import LibTmuxException
-
-from beets_flask.config import get_config
-from beets_flask.logger import log
-
-from . import sio
-
-server: libtmux.Server | None = None
-session: Session
-window: Window
-pane: Pane
-background_emit_task: asyncio.Task | None = None
-
-
-def register_tmux():
- global session, window, pane, server
-
- if server is None:
- server = libtmux.Server()
-
- try:
- abs_path_lib = str(get_config()["gui"]["terminal"]["start_path"].as_str())
- except:
- abs_path_lib = "/repo"
-
- try:
- session = server.new_session(
- session_name="beets-socket-term", start_directory=abs_path_lib
- )
- except LibTmuxException: # DuplicateSessionName
- session = server.sessions.get(session_name="beets-socket-term") # type: ignore
-
- if session is None:
- raise Exception("Could not create or find tmux session")
-
- window = session.active_window
- pane = window.active_pane or window.split_window(attach=True)
-
-
-def is_session_alive():
- try:
- if len(session.windows) > 0: # type: ignore
- # session.windows should raise if the session is not alive.
- return True
- else:
- return False
- except:
- return False
-
-
-async def emit_output():
- history = []
- x, y = 0, 0
- try:
- if is_session_alive():
- current = pane.cmd("capture-pane", "-p", "-N", "-T", "-e").stdout
- history = _get_scrollback_buffer(50)
- x, y = _get_cursor_position()
- else:
- current = ["Session ended. Reload page to restart!"]
- except Exception as e:
- log.error(f"Error reading from pty: {e}")
- current = [f"Error reading from pty: {e}"]
-
- await sio.emit(
- "ptyOutput",
- {"output": current, "x": x, "y": y, "history": history},
- namespace="/terminal",
- )
-
-
-async def emit_output_continuously(sleep_seconds=1):
- # only emit if there was a change
- prev: list[str] = []
- prev_x, prev_y = 0, 0
- history: list[str] = []
- while True:
- await asyncio.sleep(sleep_seconds)
- try:
- if is_session_alive():
- current = pane.cmd("capture-pane", "-p", "-N", "-T", "-e").stdout
- # TODO: make buffer size configurable and / or only fetch when needed.
- history = _get_scrollback_buffer(100)
- x, y = _get_cursor_position()
- else:
- current = ["Session ended. Reload page to restart!"]
- x, y = 0, 0
- if current != prev:
- await sio.emit(
- "ptyOutput",
- {"output": current, "x": x, "y": y, "history": history},
- namespace="/terminal",
- )
- prev = current
- prev_x, prev_y = x, y
- # log.debug(f"emitting {current} at {x} {y}")
- # log.debug("\n\t".join(_get_scrollback_buffer(10)) + f"\n>>> {current}")
- elif x != prev_x or y != prev_y:
- await sio.emit(
- "ptyCursorPosition", {"x": x, "y": y}, namespace="/terminal"
- )
- prev_x, prev_y = x, y
- except Exception as e:
- log.error(f"Error reading from pty: {e}")
- await sio.emit(
- "ptyOutput",
- {
- "output": f"\nError reading from pty: {e}",
- "x": 0,
- "y": 0,
- "history": history,
- },
- namespace="/terminal",
- )
- break
-
-
-def _get_scrollback_buffer(lines: int = 500) -> list[str]:
- """Fetch the last N lines of scrollback buffer from tmux.
-
- Parameters
- ----------
- pane: The tmux pane object.
- lines: Number of scrollback lines to fetch (default: 500).
-
- Returns
- -------
- List of strings representing the scrollback buffer.
- """
- try:
- # Capture the last `lines` from scrollback (excluding the current screen)
- # - '-S -N': Start from N lines before the current screen
- # - '-E -1': End at the line before the current screen
- scrollback = pane.cmd(
- "capture-pane", "-p", "-N", "-T", "-e", "-S", f"-{lines}", "-E", "-1"
- ).stdout
- return scrollback
- except Exception as e:
- log.error(f"Failed to fetch scrollback buffer: {e}")
- return []
-
-
-async def emit_cursor_position():
- try:
- x, y = _get_cursor_position()
- await sio.emit("ptyCursorPosition", {"x": x, "y": y}, namespace="/terminal")
- except Exception as e:
- log.error(f"Error reading cursor position: {e}")
-
-
-def _get_cursor_position():
- """Get the cursor position."""
- cursor = pane.cmd("display-message", "-p", "#{cursor_x},#{cursor_y}").stdout
- x, y = map(int, cursor[0].split(","))
- return x, y
-
-
-@sio.on("ptyInput", namespace="/terminal")
-async def pty_input(sid, data):
- """Write to the child pty."""
- # log.debug(f"{sid} input {data}")
- pane.send_keys(data["input"].replace(";", "\\;"), enter=False, literal=True)
- # re-emitting continuously at high rate causes quite high cpu load, therefore we
- # only re-emit at low intervals, and everytime we _know_ something changed.
- await asyncio.gather(
- emit_output(),
- )
-
-
-@sio.on("ptyResize", namespace="/terminal")
-async def resize(sid, data):
- """Resize the pty."""
- log.debug(f"{sid} resize pty to {data['cols']} {data['rows']}")
- window.resize(width=data["cols"], height=data["rows"])
- # we might want to resend the output:
- # sio.emit("ptyOutput", {"output": pane.capture_pane()}, namespace="/terminal")
-
-
-@sio.on("ptyResendOutput", namespace="/terminal")
-async def resend_output(sid):
- """Resend the output."""
- log.debug(f"{sid} resend output")
- await emit_output()
-
-
-@sio.on("connect", namespace="/terminal")
-async def connect(sid, environ):
- """Handle new client connected."""
- log.debug(f"TerminalSocket new client connected {sid}")
- register_tmux()
-
- global background_emit_task
- if background_emit_task is None:
- background_emit_task = asyncio.create_task(emit_output_continuously())
-
-
-@sio.on("disconnect", namespace="/terminal")
-async def disconnect(sid):
- """Handle client disconnect."""
- global background_emit_task
-
- # If only this client (currently about to dc) is connected,
- # we can stop the repeatedly emitting task.
- if (
- background_emit_task is not None
- and len(sio.manager.rooms.get("/terminal", {}).get(None, set())) == 1
- ):
- log.debug("No more clients connected, stopping background emit task.")
- background_emit_task.cancel()
- background_emit_task = None
- else:
- log.debug("Clients still connected, keeping background emit task running.")
-
- log.debug(f"TerminalSocket client disconnected {sid}")
-
-
-@sio.on("*", namespace="/terminal")
-async def any_event(event, sid, data):
- log.debug(f"TerminalSocket sid {sid} unhandled event {event} with data {data}")
+"""SocketIO for terminal emulation.
+
+Adapted from the excellent tutorial by cs01:
+https://github.com/cs01/pyxtermjs
+
+Notes on tmux:
+- To manually connect to the session used for the web:
+ `docker exec -it beets-flask /usr/bin/tmux attach-session -t beets-socket-term`
+- We send the whole current pane (window) content to the client, and resend when it
+changes.
+- **Currently, trailing whitespaces get stripped**. I did not manage to get a real live-representation of the input line, including trailing whitespaces. `pane.capture_pane()` uses under the hood: `pane.cmd(*["capture-pane", "-p"]).stdout`
+and we might want to play around with -T -N -J -e
+https://www.man7.org/linux/man-pages/man1/tmux.1.html
+- If we want to prepare commands client side before sending the finished command, cf.:
+https://stackoverflow.com/questions/44447473/how-to-make-xterm-js-accept-input
+
+# TODO: Typing for socket events
+
+"""
+
+from __future__ import annotations
+
+import asyncio
+
+import libtmux
+from libtmux import Pane, Session, Window
+from libtmux.exc import LibTmuxException
+
+from beets_flask.config import get_config
+from beets_flask.logger import log
+
+from . import sio
+
+server: libtmux.Server | None = None
+session: Session
+window: Window
+pane: Pane
+background_emit_task: asyncio.Task | None = None
+
+
+def register_tmux():
+ global session, window, pane, server
+
+ if server is None:
+ server = libtmux.Server()
+
+ try:
+ abs_path_lib = str(get_config()["gui"]["terminal"]["start_path"].as_str())
+ except:
+ abs_path_lib = "/repo"
+
+ try:
+ session = server.new_session(
+ session_name="beets-socket-term", start_directory=abs_path_lib
+ )
+ except LibTmuxException: # DuplicateSessionName
+ session = server.sessions.get(session_name="beets-socket-term") # type: ignore
+
+ if session is None:
+ raise Exception("Could not create or find tmux session")
+
+ window = session.active_window
+ pane = window.active_pane or window.split_window(attach=True)
+
+
+def is_session_alive():
+ try:
+ if len(session.windows) > 0: # type: ignore
+ # session.windows should raise if the session is not alive.
+ return True
+ else:
+ return False
+ except:
+ return False
+
+
+async def emit_output():
+ history = []
+ x, y = 0, 0
+ try:
+ if is_session_alive():
+ current = pane.cmd("capture-pane", "-p", "-N", "-T", "-e").stdout
+ history = _get_scrollback_buffer(50)
+ x, y = _get_cursor_position()
+ else:
+ current = ["Session ended. Reload page to restart!"]
+ except Exception as e:
+ log.error(f"Error reading from pty: {e}")
+ current = [f"Error reading from pty: {e}"]
+
+ await sio.emit(
+ "ptyOutput",
+ {"output": current, "x": x, "y": y, "history": history},
+ namespace="/terminal",
+ )
+
+
+async def emit_output_continuously(sleep_seconds=1):
+ # only emit if there was a change
+ prev: list[str] = []
+ prev_x, prev_y = 0, 0
+ history: list[str] = []
+ while True:
+ await asyncio.sleep(sleep_seconds)
+ try:
+ if is_session_alive():
+ current = pane.cmd("capture-pane", "-p", "-N", "-T", "-e").stdout
+ # TODO: make buffer size configurable and / or only fetch when needed.
+ history = _get_scrollback_buffer(100)
+ x, y = _get_cursor_position()
+ else:
+ current = ["Session ended. Reload page to restart!"]
+ x, y = 0, 0
+ if current != prev:
+ await sio.emit(
+ "ptyOutput",
+ {"output": current, "x": x, "y": y, "history": history},
+ namespace="/terminal",
+ )
+ prev = current
+ prev_x, prev_y = x, y
+ # log.debug(f"emitting {current} at {x} {y}")
+ # log.debug("\n\t".join(_get_scrollback_buffer(10)) + f"\n>>> {current}")
+ elif x != prev_x or y != prev_y:
+ await sio.emit(
+ "ptyCursorPosition", {"x": x, "y": y}, namespace="/terminal"
+ )
+ prev_x, prev_y = x, y
+ except Exception as e:
+ log.error(f"Error reading from pty: {e}")
+ await sio.emit(
+ "ptyOutput",
+ {
+ "output": f"\nError reading from pty: {e}",
+ "x": 0,
+ "y": 0,
+ "history": history,
+ },
+ namespace="/terminal",
+ )
+ break
+
+
+def _get_scrollback_buffer(lines: int = 500) -> list[str]:
+ """Fetch the last N lines of scrollback buffer from tmux.
+
+ Parameters
+ ----------
+ pane: The tmux pane object.
+ lines: Number of scrollback lines to fetch (default: 500).
+
+ Returns
+ -------
+ List of strings representing the scrollback buffer.
+ """
+ try:
+ # Capture the last `lines` from scrollback (excluding the current screen)
+ # - '-S -N': Start from N lines before the current screen
+ # - '-E -1': End at the line before the current screen
+ scrollback = pane.cmd(
+ "capture-pane", "-p", "-N", "-T", "-e", "-S", f"-{lines}", "-E", "-1"
+ ).stdout
+ return scrollback
+ except Exception as e:
+ log.error(f"Failed to fetch scrollback buffer: {e}")
+ return []
+
+
+async def emit_cursor_position():
+ try:
+ x, y = _get_cursor_position()
+ await sio.emit("ptyCursorPosition", {"x": x, "y": y}, namespace="/terminal")
+ except Exception as e:
+ log.error(f"Error reading cursor position: {e}")
+
+
+def _get_cursor_position():
+ """Get the cursor position."""
+ cursor = pane.cmd("display-message", "-p", "#{cursor_x},#{cursor_y}").stdout
+ x, y = map(int, cursor[0].split(","))
+ return x, y
+
+
+@sio.on("ptyInput", namespace="/terminal")
+async def pty_input(sid, data):
+ """Write to the child pty."""
+ # log.debug(f"{sid} input {data}")
+ pane.send_keys(data["input"].replace(";", "\\;"), enter=False, literal=True)
+ # re-emitting continuously at high rate causes quite high cpu load, therefore we
+ # only re-emit at low intervals, and everytime we _know_ something changed.
+ await asyncio.gather(
+ emit_output(),
+ )
+
+
+@sio.on("ptyResize", namespace="/terminal")
+async def resize(sid, data):
+ """Resize the pty."""
+ log.debug(f"{sid} resize pty to {data['cols']} {data['rows']}")
+ window.resize(width=data["cols"], height=data["rows"])
+ # we might want to resend the output:
+ # sio.emit("ptyOutput", {"output": pane.capture_pane()}, namespace="/terminal")
+
+
+@sio.on("ptyResendOutput", namespace="/terminal")
+async def resend_output(sid):
+ """Resend the output."""
+ log.debug(f"{sid} resend output")
+ await emit_output()
+
+
+@sio.on("connect", namespace="/terminal")
+async def connect(sid, environ):
+ """Handle new client connected."""
+ log.debug(f"TerminalSocket new client connected {sid}")
+ register_tmux()
+
+ global background_emit_task
+ if background_emit_task is None:
+ background_emit_task = asyncio.create_task(emit_output_continuously())
+
+
+@sio.on("disconnect", namespace="/terminal")
+async def disconnect(sid):
+ """Handle client disconnect."""
+ global background_emit_task
+
+ # If only this client (currently about to dc) is connected,
+ # we can stop the repeatedly emitting task.
+ if (
+ background_emit_task is not None
+ and len(sio.manager.rooms.get("/terminal", {}).get(None, set())) == 1
+ ):
+ log.debug("No more clients connected, stopping background emit task.")
+ background_emit_task.cancel()
+ background_emit_task = None
+ else:
+ log.debug("Clients still connected, keeping background emit task running.")
+
+ log.debug(f"TerminalSocket client disconnected {sid}")
+
+
+@sio.on("*", namespace="/terminal")
+async def any_event(event, sid, data):
+ log.debug(f"TerminalSocket sid {sid} unhandled event {event} with data {data}")
diff --git a/backend/beets_flask/utility.py b/backend/beets_flask/utility.py
index 68f09acf..b621279f 100644
--- a/backend/beets_flask/utility.py
+++ b/backend/beets_flask/utility.py
@@ -1,75 +1,75 @@
-import io
-import sys
-
-from deprecated import deprecated
-
-from .logger import log
-
-# ------------------------------------------------------------------------------------ #
-# Logging #
-# ------------------------------------------------------------------------------------ #
-
-
-@deprecated
-def capture_stdout_stderr(func, *args, **kwargs):
- """
- beets.ui uses a custom `print_` function to display most console output in a nicely formatted way. This is the easiest way to capture that output.
-
- Args:
- func (callable): function to call
- *args: positional arguments to pass to `func`
- **kwargs: keyword arguments to pass to `func`
-
- Returns
- -------
- tuple: (str, str, any) -- stdout, stderr, return value of `func`
- """
- original_stdout = sys.stdout
- original_stderr = sys.stderr
- buf_stdout = io.StringIO()
- buf_stderr = io.StringIO()
- sys.stdout = buf_stdout
- sys.stderr = buf_stderr
- try:
- res = func(*args, **kwargs)
- except Exception as ep:
- log.error(ep, exc_info=True)
- res = None
- sys.stdout.flush()
- sys.stderr.flush()
- sys.stdout = original_stdout
- sys.stderr = original_stderr
- return buf_stdout.getvalue(), buf_stderr.getvalue(), res
-
-
-# ------------------------------------------------------------------------------------ #
-# Misc #
-# ------------------------------------------------------------------------------------ #
-
-# audio formats supported by beets
-# https://github.com/beetbox/beets/discussions/3964
-AUDIO_EXTENSIONS = (
- "mp3",
- "aac",
- "alac",
- "ogg",
- "opus",
- "flac",
- "ape",
- "wv",
- "mpc",
- "asf",
- "aiff",
- "dsf",
-)
-
-
-class DummyObject:
- """Object that returns None for any attribute accessed.
-
- You may use this. e.g. for beets.ui._load_plugin options.
- """
-
- def __getattr__(self, name):
- """Return None for any attribute accessed."""
- return None
+import io
+import sys
+
+from deprecated import deprecated
+
+from .logger import log
+
+# ------------------------------------------------------------------------------------ #
+# Logging #
+# ------------------------------------------------------------------------------------ #
+
+
+@deprecated
+def capture_stdout_stderr(func, *args, **kwargs):
+ """
+ beets.ui uses a custom `print_` function to display most console output in a nicely formatted way. This is the easiest way to capture that output.
+
+ Args:
+ func (callable): function to call
+ *args: positional arguments to pass to `func`
+ **kwargs: keyword arguments to pass to `func`
+
+ Returns
+ -------
+ tuple: (str, str, any) -- stdout, stderr, return value of `func`
+ """
+ original_stdout = sys.stdout
+ original_stderr = sys.stderr
+ buf_stdout = io.StringIO()
+ buf_stderr = io.StringIO()
+ sys.stdout = buf_stdout
+ sys.stderr = buf_stderr
+ try:
+ res = func(*args, **kwargs)
+ except Exception as ep:
+ log.error(ep, exc_info=True)
+ res = None
+ sys.stdout.flush()
+ sys.stderr.flush()
+ sys.stdout = original_stdout
+ sys.stderr = original_stderr
+ return buf_stdout.getvalue(), buf_stderr.getvalue(), res
+
+
+# ------------------------------------------------------------------------------------ #
+# Misc #
+# ------------------------------------------------------------------------------------ #
+
+# audio formats supported by beets
+# https://github.com/beetbox/beets/discussions/3964
+AUDIO_EXTENSIONS = (
+ "mp3",
+ "aac",
+ "alac",
+ "ogg",
+ "opus",
+ "flac",
+ "ape",
+ "wv",
+ "mpc",
+ "asf",
+ "aiff",
+ "dsf",
+)
+
+
+class DummyObject:
+ """Object that returns None for any attribute accessed.
+
+ You may use this. e.g. for beets.ui._load_plugin options.
+ """
+
+ def __getattr__(self, name):
+ """Return None for any attribute accessed."""
+ return None
diff --git a/backend/beets_flask/watchdog/eventhandler.py b/backend/beets_flask/watchdog/eventhandler.py
index e18fe98e..8e08e084 100644
--- a/backend/beets_flask/watchdog/eventhandler.py
+++ b/backend/beets_flask/watchdog/eventhandler.py
@@ -1,126 +1,126 @@
-"""
-Async event handler for watchdog.
-
-Adapted from https://github.com/biesnecker/hachiko/blob/master/hachiko/hachiko.py
-MIT License
-"""
-
-import asyncio
-from collections.abc import Callable
-from pathlib import Path
-from typing import cast
-
-from watchdog.events import (
- DirCreatedEvent,
- DirDeletedEvent,
- DirModifiedEvent,
- DirMovedEvent,
- FileClosedEvent,
- FileClosedNoWriteEvent,
- FileCreatedEvent,
- FileDeletedEvent,
- FileModifiedEvent,
- FileMovedEvent,
- FileOpenedEvent,
- FileSystemEvent,
- FileSystemEventHandler,
-)
-from watchdog.observers import Observer
-from watchdog.observers.api import BaseObserver
-
-from beets_flask import log
-
-EVENT_TYPE_MOVED = "moved"
-EVENT_TYPE_DELETED = "deleted"
-EVENT_TYPE_CREATED = "created"
-EVENT_TYPE_MODIFIED = "modified"
-EVENT_TYPE_CLOSED = "closed"
-EVENT_TYPE_OPENED = "opened"
-EVENT_TYPE_CLOSED_NO_WRITE = "closed_no_write"
-
-
-class AIOEventHandler:
- """An asyncio-compatible event handler."""
-
- def __init__(self, loop=None):
- self._loop = loop or asyncio.get_event_loop()
- self._ensure_future = asyncio.create_task
- self._method_map: dict[str, Callable] = {
- EVENT_TYPE_MODIFIED: self.on_modified,
- EVENT_TYPE_MOVED: self.on_moved,
- EVENT_TYPE_CREATED: self.on_created,
- EVENT_TYPE_DELETED: self.on_deleted,
- EVENT_TYPE_CLOSED: self.on_closed,
- EVENT_TYPE_OPENED: self.on_opened,
- EVENT_TYPE_CLOSED_NO_WRITE: self.on_closed_no_write,
- }
-
- async def on_any_event(self, event: FileSystemEvent):
- raise NotImplementedError
-
- async def on_moved(self, event: DirMovedEvent | FileMovedEvent):
- pass
-
- async def on_created(self, event: DirCreatedEvent | FileCreatedEvent):
- pass
-
- async def on_deleted(self, event: DirDeletedEvent | FileDeletedEvent):
- pass
-
- async def on_modified(self, event: DirModifiedEvent | FileModifiedEvent):
- pass
-
- async def on_closed(self, event: FileClosedEvent):
- pass
-
- async def on_closed_no_write(self, event: FileClosedNoWriteEvent):
- pass
-
- async def on_opened(self, event: FileOpenedEvent):
- pass
-
- def dispatch(self, event: FileSystemEvent):
- handler = self._method_map[event.event_type]
- self._loop.call_soon_threadsafe(self._ensure_future, self.on_any_event(event))
- self._loop.call_soon_threadsafe(self._ensure_future, handler(event))
-
-
-class AIOWatchdog:
- def __init__(
- self,
- paths: list[Path],
- handler: AIOEventHandler,
- recursive=True,
- observer: BaseObserver | None = None,
- ):
- if observer is None:
- self._observer = Observer()
- else:
- self._observer = observer
-
- self._handler = handler
-
- for path in paths:
- if not path.exists():
- log.warning(
- f"Path does not exist: {path}. Check your configuration or create it."
- )
- continue
- if not path.is_dir() and not path.is_file():
- log.warning(
- f"Path is neither a file nor a directory: {path}. Check your configuration."
- )
- continue
- log.debug(f"Adding path to watchdog: {path} (recursive={recursive})")
- self._observer.schedule(
- cast(FileSystemEventHandler, self._handler),
- path=str(path.resolve()),
- recursive=recursive,
- )
-
- def start(self):
- self._observer.start()
-
- def stop(self):
- self._observer.stop()
- self._observer.join()
+"""
+Async event handler for watchdog.
+
+Adapted from https://github.com/biesnecker/hachiko/blob/master/hachiko/hachiko.py
+MIT License
+"""
+
+import asyncio
+from collections.abc import Callable
+from pathlib import Path
+from typing import cast
+
+from watchdog.events import (
+ DirCreatedEvent,
+ DirDeletedEvent,
+ DirModifiedEvent,
+ DirMovedEvent,
+ FileClosedEvent,
+ FileClosedNoWriteEvent,
+ FileCreatedEvent,
+ FileDeletedEvent,
+ FileModifiedEvent,
+ FileMovedEvent,
+ FileOpenedEvent,
+ FileSystemEvent,
+ FileSystemEventHandler,
+)
+from watchdog.observers import Observer
+from watchdog.observers.api import BaseObserver
+
+from beets_flask import log
+
+EVENT_TYPE_MOVED = "moved"
+EVENT_TYPE_DELETED = "deleted"
+EVENT_TYPE_CREATED = "created"
+EVENT_TYPE_MODIFIED = "modified"
+EVENT_TYPE_CLOSED = "closed"
+EVENT_TYPE_OPENED = "opened"
+EVENT_TYPE_CLOSED_NO_WRITE = "closed_no_write"
+
+
+class AIOEventHandler:
+ """An asyncio-compatible event handler."""
+
+ def __init__(self, loop=None):
+ self._loop = loop or asyncio.get_event_loop()
+ self._ensure_future = asyncio.create_task
+ self._method_map: dict[str, Callable] = {
+ EVENT_TYPE_MODIFIED: self.on_modified,
+ EVENT_TYPE_MOVED: self.on_moved,
+ EVENT_TYPE_CREATED: self.on_created,
+ EVENT_TYPE_DELETED: self.on_deleted,
+ EVENT_TYPE_CLOSED: self.on_closed,
+ EVENT_TYPE_OPENED: self.on_opened,
+ EVENT_TYPE_CLOSED_NO_WRITE: self.on_closed_no_write,
+ }
+
+ async def on_any_event(self, event: FileSystemEvent):
+ raise NotImplementedError
+
+ async def on_moved(self, event: DirMovedEvent | FileMovedEvent):
+ pass
+
+ async def on_created(self, event: DirCreatedEvent | FileCreatedEvent):
+ pass
+
+ async def on_deleted(self, event: DirDeletedEvent | FileDeletedEvent):
+ pass
+
+ async def on_modified(self, event: DirModifiedEvent | FileModifiedEvent):
+ pass
+
+ async def on_closed(self, event: FileClosedEvent):
+ pass
+
+ async def on_closed_no_write(self, event: FileClosedNoWriteEvent):
+ pass
+
+ async def on_opened(self, event: FileOpenedEvent):
+ pass
+
+ def dispatch(self, event: FileSystemEvent):
+ handler = self._method_map[event.event_type]
+ self._loop.call_soon_threadsafe(self._ensure_future, self.on_any_event(event))
+ self._loop.call_soon_threadsafe(self._ensure_future, handler(event))
+
+
+class AIOWatchdog:
+ def __init__(
+ self,
+ paths: list[Path],
+ handler: AIOEventHandler,
+ recursive=True,
+ observer: BaseObserver | None = None,
+ ):
+ if observer is None:
+ self._observer = Observer()
+ else:
+ self._observer = observer
+
+ self._handler = handler
+
+ for path in paths:
+ if not path.exists():
+ log.warning(
+ f"Path does not exist: {path}. Check your configuration or create it."
+ )
+ continue
+ if not path.is_dir() and not path.is_file():
+ log.warning(
+ f"Path is neither a file nor a directory: {path}. Check your configuration."
+ )
+ continue
+ log.debug(f"Adding path to watchdog: {path} (recursive={recursive})")
+ self._observer.schedule(
+ cast(FileSystemEventHandler, self._handler),
+ path=str(path.resolve()),
+ recursive=recursive,
+ )
+
+ def start(self):
+ self._observer.start()
+
+ def stop(self):
+ self._observer.stop()
+ self._observer.join()
diff --git a/backend/beets_flask/watchdog/inbox.py b/backend/beets_flask/watchdog/inbox.py
index c86ddfe0..ec62987d 100644
--- a/backend/beets_flask/watchdog/inbox.py
+++ b/backend/beets_flask/watchdog/inbox.py
@@ -1,233 +1,263 @@
-import asyncio
-import os
-import signal
-from collections import OrderedDict
-from pathlib import Path
-
-from watchdog.events import FileMovedEvent, FileSystemEvent
-from watchdog.observers.polling import PollingObserver
-
-from beets_flask import invoker
-from beets_flask.config import get_config
-from beets_flask.database.models.states import SessionStateInDb
-from beets_flask.disk import (
- album_folders_from_track_paths,
- all_album_folders,
- fs_item_from_path,
-)
-from beets_flask.invoker import enqueue
-from beets_flask.logger import log
-from beets_flask.server.websocket.status import FileSystemUpdate, send_status_update
-from beets_flask.watchdog.eventhandler import AIOEventHandler, AIOWatchdog
-
-# ------------------------------------------------------------------------------------ #
-# init and watchdog #
-# ------------------------------------------------------------------------------------ #
-
-
-def register_inboxes(timeout: float = 2.5, debounce: float = 30) -> AIOWatchdog | None:
- """
- Register file system watcher to monitor configured inboxes.
-
- Parameters
- ----------
- timeout: float
- Timeout for the polling observer in seconds (heartbeat, to recheck file system changes)
- debounce: float
- Debounce window in seconds, to wait before starting tagging operations.
- This is to avoid multiple triggers for changes in the same folder.
- You have to wait at least this long before an autotag will trigger
- after you add the last file to an inbox.
- Default is 30 seconds.
-
- Notes
- -----
- - This should not be called from uvicorn workers to avoid concurrency issues.
- You only want one watchdog (use separate init script).
- """
- _inboxes = get_inboxes()
-
- if os.environ.get("RQ_WORKER_ID", None):
- # only launch the observer on the main process
- log.exception("WTF, redis what you doing?")
- return None
-
- # Return early if no inboxes are configured.
- if len(_inboxes) == 0:
- log.info("Skipping watchdog, no inboxes configured")
- return None
- log.info(
- f"Registering watchdog with debounce of {debounce} seconds for "
- + f"inboxes: {[i['path'] for i in _inboxes]}"
- )
-
- # One observer for all inboxes.
- handler = InboxHandler(debounce_window=debounce)
- observer = PollingObserver(timeout=timeout)
- # timeout/debounce in seconds
-
- watchdog = AIOWatchdog(
- paths=[Path(i["path"]) for i in _inboxes],
- handler=handler,
- observer=observer,
- )
-
- watchdog.start()
-
- # Stop watchdog on exit signals.
- signal.signal(signal.SIGINT, lambda s, f: watchdog.stop())
- signal.signal(signal.SIGHUP, lambda s, f: watchdog.stop())
- signal.signal(signal.SIGTERM, lambda s, f: watchdog.stop())
- signal.signal(signal.SIGQUIT, lambda s, f: watchdog.stop())
-
- # user would expect autotagging inboxes to automatically scan on first launch
- async def auto_tag_wait_for_workers(f: Path):
- # HACK: checking if redis is ready was not trivial enough, so we just wait a bit.
- await asyncio.sleep(10)
- await auto_tag(f)
-
- auto_inboxes = [i for i in _inboxes if i.get("autotag", None)]
-
- for inbox in auto_inboxes:
- album_folders = all_album_folders(inbox["path"])
- for f in album_folders:
- asyncio.create_task(auto_tag_wait_for_workers(f))
-
- return watchdog
-
-
-class InboxHandler(AIOEventHandler):
- debounce: dict[str, asyncio.Task]
-
- def __init__(self, debounce_window: float) -> None:
- super().__init__()
- self.debounce = {}
- self.debounce_window = debounce_window
-
- async def on_any_event(self, event: FileSystemEvent):
- log.debug("Watchdog: got %r", event)
-
- if isinstance(event, FileMovedEvent):
- fullpath = str(event.dest_path)
- else:
- fullpath = str(event.src_path)
- if os.path.basename(fullpath).startswith("."):
- return
-
- # trigger cache clear and gui update of inbox directories
- status_update = asyncio.create_task(send_status_update(FileSystemUpdate()))
-
- try:
- log.debug(f"File change at {fullpath}")
- album_folder = album_folders_from_track_paths([fullpath])[0]
- except IndexError:
- log.debug(f"File change at {fullpath} but is no album_folder")
- return
-
- album_folder_key = str(album_folder.resolve())
- task = asyncio.create_task(self.task_func(album_folder))
- if current := self.debounce.get(album_folder_key, None):
- try:
- current.cancel()
- except Exception as e:
- log.error(f"Error cancelling previous task for {album_folder_key}: {e}")
-
- self.debounce[album_folder_key] = task
- await status_update
-
- async def task_func(self, album_folder: Path):
- await asyncio.sleep(self.debounce_window)
- log.info(f"Watchdog: Starting inbox handler task {album_folder}")
- try:
- await auto_tag(album_folder)
- except Exception as e:
- log.exception(f"Error in inbox handler task for {album_folder}", e)
-
-
-async def auto_tag(folder_path: Path, inbox_kind: str | None = None):
- """Retag a (taggable) folder.
-
- Parameters
- ----------
- path: str
- Full path to the folder or archive file to retag.
- kind: str, optional
- If None, the configured autotag kind from the inbox this folder is in will be used.
- """
- inbox = get_inbox_for_path(folder_path)
- if inbox is None:
- log.error(f"Path {folder_path} is not in any inbox, skipping autotagging.")
- return
-
- if inbox_kind is None:
- inbox_kind = inbox.get("autotag", None)
-
- # Infer enqueue kind from inbox kind
- enq_kind: invoker.EnqueueKind
- enq_kwargs = {}
- match inbox_kind:
- case "preview":
- enq_kind = invoker.EnqueueKind.PREVIEW
- case "auto":
- enq_kind = invoker.EnqueueKind.IMPORT_AUTO
- enq_kwargs["import_threshold"] = inbox.get("auto_threshold", None)
- case "bootleg":
- enq_kind = invoker.EnqueueKind.IMPORT_BOOTLEG
- case False | None:
- log.debug(f"Autotagging disabled for {folder_path}, skipping.")
- return
- case _:
- log.error(f"Unknown autotagging kind {inbox_kind} for {folder_path}")
- return
-
- folder = fs_item_from_path(folder_path)
- if not folder.is_album:
- log.info(f"Path {folder_path} is not an album folder, skipping autotagging.")
- return
-
- # check if we have a session for this folder already.
- # if so, skip imports but update the previews.
- state = SessionStateInDb.get_by_hash_and_path(hash=None, path=folder.full_path)
-
- should_enqueue = False
- if state is None:
- should_enqueue = True
- else:
- # keeps previews fresh when we have integrity warnings (i.e. content changed)
- if enq_kind == invoker.EnqueueKind.PREVIEW and folder.hash != state.folder_hash:
- should_enqueue = True
-
- if should_enqueue:
- log.info(f"Watchdog: Enqueuing {folder.full_path} as {enq_kind.value}")
- await enqueue(folder.hash, folder.full_path, kind=enq_kind, **enq_kwargs)
- else:
- log.info(f"Watchdog: skipping enqueue {folder.full_path}")
-
-
-# ------------------------------------------------------------------------------------ #
-# inboxes #
-# ------------------------------------------------------------------------------------ #
-
-
-def get_inbox_for_path(path: str | Path):
- if isinstance(path, str):
- path = Path(path)
- inbox = None
- for i in get_inboxes():
- ipath = Path(i["path"])
- if path.is_relative_to(ipath) or path == ipath:
- inbox = i
- break
- return inbox
-
-
-def get_inbox_folders() -> list[str]:
- return [i["path"] for i in get_inboxes()]
-
-
-def is_inbox_folder(path: str) -> bool:
- return path in get_inbox_folders()
-
-
-def get_inboxes() -> list[OrderedDict]:
- return get_config()["gui"]["inbox"]["folders"].flatten().values() # type: ignore
+import asyncio
+import os
+import signal
+from collections import OrderedDict
+from pathlib import Path
+
+from watchdog.events import FileMovedEvent, FileSystemEvent
+from watchdog.observers.polling import PollingObserver
+
+from beets_flask import invoker
+from beets_flask.config import get_config
+from beets_flask.database.models.states import SessionStateInDb
+from beets_flask.disk import (
+ _matches_patterns,
+ album_folders_from_track_paths,
+ all_album_folders,
+ compute_and_store_dir_stats,
+ fs_item_from_path,
+)
+from beets_flask.invoker import enqueue
+from beets_flask.logger import log
+from beets_flask.server.websocket.status import FileSystemUpdate, send_status_update
+from beets_flask.watchdog.eventhandler import AIOEventHandler, AIOWatchdog
+
+# ------------------------------------------------------------------------------------ #
+# init and watchdog #
+# ------------------------------------------------------------------------------------ #
+
+
+def register_inboxes(timeout: float = 2.5, debounce: float = 30) -> AIOWatchdog | None:
+ """
+ Register file system watcher to monitor configured inboxes.
+
+ Parameters
+ ----------
+ timeout: float
+ Timeout for the polling observer in seconds (heartbeat, to recheck file system changes)
+ debounce: float
+ Debounce window in seconds, to wait before starting tagging operations.
+ This is to avoid multiple triggers for changes in the same folder.
+ You have to wait at least this long before an autotag will trigger
+ after you add the last file to an inbox.
+ Default is 30 seconds.
+
+ Notes
+ -----
+ - This should not be called from uvicorn workers to avoid concurrency issues.
+ You only want one watchdog (use separate init script).
+ """
+ _inboxes = get_inboxes()
+
+ if os.environ.get("RQ_WORKER_ID", None):
+ # only launch the observer on the main process
+ log.exception("WTF, redis what you doing?")
+ return None
+
+ # Return early if no inboxes are configured.
+ if len(_inboxes) == 0:
+ log.info("Skipping watchdog, no inboxes configured")
+ return None
+ log.info(
+ f"Registering watchdog with debounce of {debounce} seconds for "
+ + f"inboxes: {[i['path'] for i in _inboxes]}"
+ )
+
+ # One observer for all inboxes.
+ handler = InboxHandler(debounce_window=debounce)
+ observer = PollingObserver(timeout=timeout)
+ # timeout/debounce in seconds
+
+ watchdog = AIOWatchdog(
+ paths=[Path(i["path"]) for i in _inboxes],
+ handler=handler,
+ observer=observer,
+ )
+
+ watchdog.start()
+
+ # Stop watchdog on exit signals.
+ signal.signal(signal.SIGINT, lambda s, f: watchdog.stop())
+ signal.signal(signal.SIGHUP, lambda s, f: watchdog.stop())
+ signal.signal(signal.SIGTERM, lambda s, f: watchdog.stop())
+ signal.signal(signal.SIGQUIT, lambda s, f: watchdog.stop())
+
+ # user would expect autotagging inboxes to automatically scan on first launch
+ async def auto_tag_wait_for_workers(f: Path):
+ # HACK: checking if redis is ready was not trivial enough, so we just wait a bit.
+ await asyncio.sleep(10)
+ await auto_tag(f)
+
+ auto_inboxes = [i for i in _inboxes if i.get("autotag", None)]
+ ignore_globs = get_config().ignore_globs
+
+ for inbox in auto_inboxes:
+ album_folders = all_album_folders(inbox["path"])
+ for f in album_folders:
+ if any(_matches_patterns(part, ignore_globs) for part in f.parts):
+ log.debug(f"Watchdog: Skipping ignored path {f}")
+ continue
+ asyncio.create_task(auto_tag_wait_for_workers(f))
+
+ # Pre-compute stats for all inboxes so the home page is fast on first load.
+ async def _compute_startup_stats():
+ await asyncio.gather(
+ *[compute_and_store_dir_stats(Path(inbox["path"])) for inbox in _inboxes]
+ )
+ log.info("Watchdog startup complete: inbox stats pre-computed for all inboxes")
+
+ asyncio.create_task(_compute_startup_stats())
+
+ return watchdog
+
+
+class InboxHandler(AIOEventHandler):
+ debounce: dict[str, asyncio.Task]
+
+ def __init__(self, debounce_window: float) -> None:
+ super().__init__()
+ self.debounce = {}
+ self.debounce_window = debounce_window
+
+ async def on_any_event(self, event: FileSystemEvent):
+ log.debug("Watchdog: got %r", event)
+
+ if isinstance(event, FileMovedEvent):
+ fullpath = str(event.dest_path)
+ else:
+ fullpath = str(event.src_path)
+ if os.path.basename(fullpath).startswith("."):
+ return
+
+ # trigger cache clear and gui update of inbox directories
+ status_update = asyncio.create_task(send_status_update(FileSystemUpdate()))
+
+ try:
+ log.debug(f"File change at {fullpath}")
+ album_folder = album_folders_from_track_paths([fullpath])[0]
+ except IndexError:
+ log.debug(f"File change at {fullpath} but is no album_folder")
+ return
+
+ album_folder_key = str(album_folder.resolve())
+ task = asyncio.create_task(self.task_func(album_folder))
+ if current := self.debounce.get(album_folder_key, None):
+ try:
+ current.cancel()
+ except Exception as e:
+ log.error(f"Error cancelling previous task for {album_folder_key}: {e}")
+
+ self.debounce[album_folder_key] = task
+ await status_update
+
+ async def task_func(self, album_folder: Path):
+ await asyncio.sleep(self.debounce_window)
+ log.info(f"Watchdog: Starting inbox handler task {album_folder}")
+ try:
+ await auto_tag(album_folder)
+ except Exception as e:
+ log.exception(f"Error in inbox handler task for {album_folder}", e)
+
+ # Refresh the cached stats for the inbox that contains this album so
+ # the home page reflects the current file count / size without running
+ # an expensive subprocess on every request.
+ inbox = get_inbox_for_path(album_folder)
+ if inbox:
+ try:
+ await compute_and_store_dir_stats(Path(inbox["path"]))
+ except Exception as e:
+ log.exception(f"Error computing stats for inbox {inbox['path']}", e)
+
+
+async def auto_tag(folder_path: Path, inbox_kind: str | None = None):
+ """Retag a (taggable) folder.
+
+ Parameters
+ ----------
+ path: str
+ Full path to the folder or archive file to retag.
+ kind: str, optional
+ If None, the configured autotag kind from the inbox this folder is in will be used.
+ """
+ inbox = get_inbox_for_path(folder_path)
+ if inbox is None:
+ log.error(f"Path {folder_path} is not in any inbox, skipping autotagging.")
+ return
+
+ ignore_globs = get_config().ignore_globs
+ if any(_matches_patterns(part, ignore_globs) for part in folder_path.parts):
+ log.debug(f"Path {folder_path} is in an ignored directory, skipping autotagging.")
+ return
+
+ if inbox_kind is None:
+ inbox_kind = inbox.get("autotag", None)
+
+ # Infer enqueue kind from inbox kind
+ enq_kind: invoker.EnqueueKind
+ enq_kwargs = {}
+ match inbox_kind:
+ case "preview":
+ enq_kind = invoker.EnqueueKind.PREVIEW
+ case "auto":
+ enq_kind = invoker.EnqueueKind.IMPORT_AUTO
+ enq_kwargs["import_threshold"] = inbox.get("auto_threshold", None)
+ case "bootleg":
+ enq_kind = invoker.EnqueueKind.IMPORT_BOOTLEG
+ case False | None:
+ log.debug(f"Autotagging disabled for {folder_path}, skipping.")
+ return
+ case _:
+ log.error(f"Unknown autotagging kind {inbox_kind} for {folder_path}")
+ return
+
+ folder = fs_item_from_path(folder_path)
+ if not folder.is_album:
+ log.info(f"Path {folder_path} is not an album folder, skipping autotagging.")
+ return
+
+ # check if we have a session for this folder already.
+ # if so, skip imports but update the previews.
+ state = SessionStateInDb.get_by_hash_and_path(hash=None, path=folder.full_path)
+
+ should_enqueue = False
+ if state is None:
+ should_enqueue = True
+ else:
+ # keeps previews fresh when we have integrity warnings (i.e. content changed)
+ if enq_kind == invoker.EnqueueKind.PREVIEW and folder.hash != state.folder_hash:
+ should_enqueue = True
+
+ if should_enqueue:
+ log.info(f"Watchdog: Enqueuing {folder.full_path} as {enq_kind.value}")
+ await enqueue(folder.hash, folder.full_path, kind=enq_kind, **enq_kwargs)
+ else:
+ log.info(f"Watchdog: skipping enqueue {folder.full_path}")
+
+
+# ------------------------------------------------------------------------------------ #
+# inboxes #
+# ------------------------------------------------------------------------------------ #
+
+
+def get_inbox_for_path(path: str | Path):
+ if isinstance(path, str):
+ path = Path(path)
+ inbox = None
+ for i in get_inboxes():
+ ipath = Path(i["path"])
+ if path.is_relative_to(ipath) or path == ipath:
+ inbox = i
+ break
+ return inbox
+
+
+def get_inbox_folders() -> list[str]:
+ return [i["path"] for i in get_inboxes()]
+
+
+def is_inbox_folder(path: str) -> bool:
+ return path in get_inbox_folders()
+
+
+def get_inboxes() -> list[OrderedDict]:
+ return get_config()["gui"]["inbox"]["folders"].flatten().values() # type: ignore
diff --git a/backend/generate_types.py b/backend/generate_types.py
index 369894c8..4e08af96 100644
--- a/backend/generate_types.py
+++ b/backend/generate_types.py
@@ -1,84 +1,84 @@
-from py2ts.builder import TSBuilder
-from py2ts.config import CONFIG
-
-from beets_flask.disk import Archive, File, FileSystemItem, Folder
-from beets_flask.importer.session import CandidateChoiceFallback
-from beets_flask.importer.states import (
- SerializedSessionState,
-)
-from beets_flask.invoker import EnqueueKind
-from beets_flask.invoker.enqueue import (
- Search,
-)
-from beets_flask.server.routes.inbox import InboxStats
-from beets_flask.server.routes.library.resources import (
- AlbumResponse,
- AlbumResponseExpanded,
- AlbumResponseMinimal,
- AlbumResponseMinimalExpanded,
- ItemResponse,
- ItemResponseMinimal,
-)
-from beets_flask.server.routes.library.stats import LibraryStats
-from beets_flask.server.websocket.status import (
- FileSystemUpdate,
- FolderStatusUpdate,
- JobStatusUpdate,
-)
-
-prefix = """/*
- * This file is generated by ../backend/generate_types.py
- * Do not edit this file manually!
- */
-"""
-
-CONFIG.any_as_unknown = True
-CONFIG.indent_with_tabs = False
-CONFIG.indent_size = 4
-
-builder = TSBuilder()
-
-
-# Session state
-builder.add(SerializedSessionState)
-
-# ------------------------------- inbox routes ------------------------------- #
-
-# Folder
-builder.add(FileSystemItem)
-builder.add(File)
-builder.add(Archive)
-builder.add(Folder)
-builder.add(InboxStats)
-
-
-# Invoker / enqueue
-builder.add(EnqueueKind)
-builder.add(Search)
-builder.add(CandidateChoiceFallback)
-
-
-# ------------------------------ library routes ------------------------------ #
-
-# Stats
-builder.add(LibraryStats)
-
-# Item responses
-builder.add(ItemResponse)
-builder.add(ItemResponseMinimal)
-
-# Album responses
-builder.add(AlbumResponse)
-builder.add(AlbumResponseMinimal)
-builder.add(AlbumResponseExpanded)
-builder.add(AlbumResponseMinimalExpanded)
-
-# ------------------------------ Status updates ------------------------------ #
-
-builder.add(FolderStatusUpdate)
-builder.add(JobStatusUpdate)
-builder.add(FileSystemUpdate)
-
-
-builder.save_file("../frontend/src/pythonTypes.ts")
-print("✅ Typescript types generated successfully!")
+from py2ts.builder import TSBuilder
+from py2ts.config import CONFIG
+
+from beets_flask.disk import Archive, File, FileSystemItem, Folder
+from beets_flask.importer.session import CandidateChoiceFallback
+from beets_flask.importer.states import (
+ SerializedSessionState,
+)
+from beets_flask.invoker import EnqueueKind
+from beets_flask.invoker.enqueue import (
+ Search,
+)
+from beets_flask.server.routes.inbox import InboxStats
+from beets_flask.server.routes.library.resources import (
+ AlbumResponse,
+ AlbumResponseExpanded,
+ AlbumResponseMinimal,
+ AlbumResponseMinimalExpanded,
+ ItemResponse,
+ ItemResponseMinimal,
+)
+from beets_flask.server.routes.library.stats import LibraryStats
+from beets_flask.server.websocket.status import (
+ FileSystemUpdate,
+ FolderStatusUpdate,
+ JobStatusUpdate,
+)
+
+prefix = """/*
+ * This file is generated by ../backend/generate_types.py
+ * Do not edit this file manually!
+ */
+"""
+
+CONFIG.any_as_unknown = True
+CONFIG.indent_with_tabs = False
+CONFIG.indent_size = 4
+
+builder = TSBuilder()
+
+
+# Session state
+builder.add(SerializedSessionState)
+
+# ------------------------------- inbox routes ------------------------------- #
+
+# Folder
+builder.add(FileSystemItem)
+builder.add(File)
+builder.add(Archive)
+builder.add(Folder)
+builder.add(InboxStats)
+
+
+# Invoker / enqueue
+builder.add(EnqueueKind)
+builder.add(Search)
+builder.add(CandidateChoiceFallback)
+
+
+# ------------------------------ library routes ------------------------------ #
+
+# Stats
+builder.add(LibraryStats)
+
+# Item responses
+builder.add(ItemResponse)
+builder.add(ItemResponseMinimal)
+
+# Album responses
+builder.add(AlbumResponse)
+builder.add(AlbumResponseMinimal)
+builder.add(AlbumResponseExpanded)
+builder.add(AlbumResponseMinimalExpanded)
+
+# ------------------------------ Status updates ------------------------------ #
+
+builder.add(FolderStatusUpdate)
+builder.add(JobStatusUpdate)
+builder.add(FileSystemUpdate)
+
+
+builder.save_file("../frontend/src/pythonTypes.ts")
+print("✅ Typescript types generated successfully!")
diff --git a/backend/launch_db_init.py b/backend/launch_db_init.py
index cb59148e..81af1039 100644
--- a/backend/launch_db_init.py
+++ b/backend/launch_db_init.py
@@ -1,21 +1,21 @@
-import os
-
-# dirty workaround, we pretend this is a rq worker so we get the logger to create
-# a child log with pid
-os.environ.setdefault("RQ_JOB_ID", "dbin")
-
-from beets.ui import _open_library
-
-from beets_flask.config.beets_config import get_config
-from beets_flask.database import setup_database
-from beets_flask.logger import log
-
-if __name__ == "__main__":
- log.debug("Launching database init worker")
-
- # ensue beets own db is created
- config = get_config()
- _open_library(config)
-
- # ensure beets-flask db is created
- setup_database()
+import os
+
+# dirty workaround, we pretend this is a rq worker so we get the logger to create
+# a child log with pid
+os.environ.setdefault("RQ_JOB_ID", "dbin")
+
+from beets.ui import _open_library
+
+from beets_flask.config.beets_config import get_config
+from beets_flask.database import setup_database
+from beets_flask.logger import log
+
+if __name__ == "__main__":
+ log.debug("Launching database init worker")
+
+ # ensue beets own db is created
+ config = get_config()
+ _open_library(config)
+
+ # ensure beets-flask db is created
+ setup_database()
diff --git a/backend/launch_redis_workers.py b/backend/launch_redis_workers.py
index ef0ae168..18d6aa4d 100644
--- a/backend/launch_redis_workers.py
+++ b/backend/launch_redis_workers.py
@@ -1,23 +1,23 @@
-import os
-
-from beets_flask.config.beets_config import get_config
-from beets_flask.logger import log
-
-num_preview_workers: int = 1 # Default value
-try:
- num_preview_workers = get_config()["gui"]["num_preview_workers"].get(int) # type: ignore
- log.debug(f"Got num_preview_workers from config: {num_preview_workers}")
-except:
- pass
-
-log.info(f"Starting {num_preview_workers} redis workers for preview generation")
-for i in range(num_preview_workers):
- os.system(f'rq worker preview --log-format "Preview worker $i: %(message)s" &')
-
-
-# imports are relatively fast, because they use previously fetched previews.
-# one worker should be enough, and this avoids problems from simultaneous db writes etc.
-num_import_workers = 1
-log.info(f"Starting {num_import_workers} redis workers for import")
-for i in range(num_import_workers):
- os.system(f'rq worker import --log-format "Import worker $i: %(message)s" &')
+import os
+
+from beets_flask.config.beets_config import get_config
+from beets_flask.logger import log
+
+num_preview_workers: int = 1 # Default value
+try:
+ num_preview_workers = get_config()["gui"]["num_preview_workers"].get(int) # type: ignore
+ log.debug(f"Got num_preview_workers from config: {num_preview_workers}")
+except:
+ pass
+
+log.info(f"Starting {num_preview_workers} redis workers for preview generation")
+for i in range(num_preview_workers):
+ os.system(f'rq worker preview --log-format "Preview worker $i: %(message)s" &')
+
+
+# imports are relatively fast, because they use previously fetched previews.
+# one worker should be enough, and this avoids problems from simultaneous db writes etc.
+num_import_workers = 1
+log.info(f"Starting {num_import_workers} redis workers for import")
+for i in range(num_import_workers):
+ os.system(f'rq worker import --log-format "Import worker $i: %(message)s" &')
diff --git a/backend/launch_watchdog_worker.py b/backend/launch_watchdog_worker.py
index 3e2ae3c4..073a7b5a 100644
--- a/backend/launch_watchdog_worker.py
+++ b/backend/launch_watchdog_worker.py
@@ -1,24 +1,24 @@
-import asyncio
-import os
-
-# dirty workaround, we pretend this is a rq worker so we get the logger to create
-# a child log with pid
-os.environ.setdefault("RQ_JOB_ID", "wdog")
-
-from beets_flask.config import get_config
-from beets_flask.logger import log
-from beets_flask.watchdog.inbox import register_inboxes
-
-
-async def main():
- log.debug(f"Launching inbox watchdog worker")
- config = get_config()
- debounce = int(
- config["gui"]["inbox"]["debounce_before_autotag"].as_number() # type: ignore
- )
- watchdog = register_inboxes(debounce=debounce)
-
-
-if __name__ == "__main__":
- asyncio.run(main())
- asyncio.get_event_loop().run_forever()
+import asyncio
+import os
+
+# dirty workaround, we pretend this is a rq worker so we get the logger to create
+# a child log with pid
+os.environ.setdefault("RQ_JOB_ID", "wdog")
+
+from beets_flask.config import get_config
+from beets_flask.logger import log
+from beets_flask.watchdog.inbox import register_inboxes
+
+
+async def main():
+ log.debug(f"Launching inbox watchdog worker")
+ config = get_config()
+ debounce = int(
+ config["gui"]["inbox"]["debounce_before_autotag"].as_number() # type: ignore
+ )
+ watchdog = register_inboxes(debounce=debounce)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+ asyncio.get_event_loop().run_forever()
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 2be7b33e..554d085a 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -1,7 +1,7 @@
[project]
name = "beets-flask"
description = "An opinionated web-interface around the music organizer [beets](https://beets.io/)"
-version = "1.2.1"
+version = "1.2.3"
authors = [
{ name = "F. Paul Spitzner", email = "paul.spitzner@gmail.com" },
{ name = "Sebastian B. Mohr", email = "sebastian@mohrenclan.de" },
@@ -41,6 +41,7 @@ dependencies = [
"numpy",
"pandas",
"typing_extensions",
+ "pytz",
]
[project.optional-dependencies]
@@ -124,6 +125,7 @@ filterwarnings = [
"error",
"ignore::sqlalchemy.exc.SAWarning",
"ignore::DeprecationWarning",
+ "ignore::pytest.PytestUnraisableExceptionWarning",
]
pythonpath = ["."]
asyncio_mode = "auto"
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index 20c086ae..8a556f24 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -1,199 +1,199 @@
-import logging
-import os
-import shutil
-from collections.abc import Callable, Generator
-from contextlib import _GeneratorContextManager
-from pathlib import Path
-
-import pytest
-from quart import Quart
-from quart.typing import TestClientProtocol
-from sqlalchemy.orm import Session
-
-from beets_flask.importer.types import BeetsLibrary
-from beets_flask.server.app import create_app
-
-log = logging.getLogger(__name__)
-
-
-@pytest.fixture(autouse=True, scope="session")
-def setup_and_teardown(tmpdir_factory):
- """General setup and teardown for the tests
-
- This creates a temporary directory for the beets library and
- the beets-flask configuration.
- Also sets all environment variables needed for the tests.
- """
-
- # Setup beets to use the tempdir
- # for reference, see also
- # https://github.com/beetbox/beets/blob/22163d70a77449d83670e60ad3758474463de31b/beets/test/helper.py#L196
- tmp_dir = tmpdir_factory.mktemp("beets_flask")
- os.environ["HOME"] = str(tmp_dir)
- os.environ["BEETSDIR"] = str(tmp_dir / "beets")
- os.makedirs(name=tmp_dir / "beets", exist_ok=True)
- os.environ["BEETSFLASKDIR"] = str(tmp_dir / "beets-flask")
- os.makedirs(name=tmp_dir / "beets-flask", exist_ok=True)
- os.environ["IB_SERVER_CONFIG"] = "test"
-
- yield
-
- # Teardown
- shutil.rmtree(tmp_dir)
-
-
-@pytest.fixture(name="testapp", scope="session")
-def fixture_testapp():
- app = create_app("test")
-
- yield app
-
-
-@pytest.fixture(name="client")
-def fixture_client(testapp: Quart) -> TestClientProtocol:
- # Needs beets_lib to be initialized for environment variables
- return testapp.test_client()
-
-
-@pytest.fixture(name="runner")
-def fixture_runner(app):
- return app.test_cli_runner()
-
-
-# ----------------------------- Database fixtures ---------------------------- #
-# Both for our and the beets database
-
-
-@pytest.fixture(name="db_session_factory")
-def db_session_factory(
- testapp,
-) -> Callable[..., _GeneratorContextManager[Session, None, None]]:
- from beets_flask.database import db_session_factory
-
- return db_session_factory
-
-
-@pytest.fixture(name="db_session")
-def db_session(db_session_factory):
- with db_session_factory() as session:
- yield session
-
-
-@pytest.fixture(scope="function")
-def beets_lib() -> Generator[BeetsLibrary, None, None]:
- import beets.library
-
- from beets_flask.config.beets_config import refresh_config
-
- lib = beets.library.Library(path=os.environ["BEETSDIR"] + "/library.db")
-
- refresh_config()
-
- # Copy test audio data
- source = Path(__file__).parent / "data" / "audio"
- dest = Path(os.environ["HOME"]) / "audio"
- Path(dest).mkdir(exist_ok=True)
-
- shutil.copytree(source, dest, dirs_exist_ok=True)
-
- yield lib
-
- os.remove(os.environ["BEETSDIR"] + "/library.db")
-
- # Clean up the copied files
- shutil.rmtree(dest)
-
-
-def beets_lib_item(**kwargs):
- """
- Usage:
-
- beets_lib.add(item(title="the title", artist="the artist", album="the album"))
- """
- import beets.library
-
- default_kwargs = dict(
- title="the title",
- artist="the artist",
- albumartist="the album artist",
- album="the album",
- genre="the genre",
- lyricist="the lyricist",
- composer="the composer",
- arranger="the arranger",
- grouping="the grouping",
- year=1,
- month=2,
- day=3,
- track=4,
- tracktotal=5,
- disc=6,
- disctotal=7,
- lyrics="the lyrics",
- comments="the comments",
- path=os.environ["HOME"] + "/audio/test.mp3",
- )
- default_kwargs.update(kwargs)
-
- i = beets.library.Item(
- None,
- **default_kwargs,
- )
- return i
-
-
-def beets_lib_album(**kwargs):
- """
- Usage:
-
- beets_lib.add(album(title="the title", artist="the artist"))
- """
- import beets.library
-
- default_kwargs = dict(
- album="the album",
- albumartist="the album artist",
- genre="the genre",
- year=1,
- month=2,
- day=3,
- tracktotal=5,
- disctotal=7,
- lyrics="the lyrics",
- comments="the comments",
- artpath=os.environ["HOME"] + "/audio/cover.png",
- )
- default_kwargs.update(kwargs)
-
- a = beets.library.Album(
- db=None,
- **default_kwargs,
- )
- return a
-
-
-# ---------------------------------- Mocking --------------------------------- #
-
-
-@pytest.fixture(autouse=True)
-def local_redis(monkeypatch):
- """Mock all redis calls with rq
- see https://python-rq.org/docs/testing/
- see https://docs.pytest.org/en/7.1.x/how-to/monkeypatch.html#global-patch-example-preventing-requests-from-remote-operations
- """
-
- from fakeredis import FakeStrictRedis
- from rq import Queue
-
- log.debug("Mocking beets_flask.redis")
- monkeypatch.setattr(
- "beets_flask.redis.import_queue",
- Queue("import", is_async=False, connection=FakeStrictRedis()),
- )
- monkeypatch.setattr(
- "beets_flask.redis.preview_queue",
- Queue("preview", is_async=False, connection=FakeStrictRedis()),
- )
- yield
- log.debug("Unmocking beets_flask.redis")
- monkeypatch.undo()
+import logging
+import os
+import shutil
+from collections.abc import Callable, Generator
+from contextlib import _GeneratorContextManager
+from pathlib import Path
+
+import pytest
+from quart import Quart
+from quart.typing import TestClientProtocol
+from sqlalchemy.orm import Session
+
+from beets_flask.importer.types import BeetsLibrary
+from beets_flask.server.app import create_app
+
+log = logging.getLogger(__name__)
+
+
+@pytest.fixture(autouse=True, scope="session")
+def setup_and_teardown(tmpdir_factory):
+ """General setup and teardown for the tests
+
+ This creates a temporary directory for the beets library and
+ the beets-flask configuration.
+ Also sets all environment variables needed for the tests.
+ """
+
+ # Setup beets to use the tempdir
+ # for reference, see also
+ # https://github.com/beetbox/beets/blob/22163d70a77449d83670e60ad3758474463de31b/beets/test/helper.py#L196
+ tmp_dir = tmpdir_factory.mktemp("beets_flask")
+ os.environ["HOME"] = str(tmp_dir)
+ os.environ["BEETSDIR"] = str(tmp_dir / "beets")
+ os.makedirs(name=tmp_dir / "beets", exist_ok=True)
+ os.environ["BEETSFLASKDIR"] = str(tmp_dir / "beets-flask")
+ os.makedirs(name=tmp_dir / "beets-flask", exist_ok=True)
+ os.environ["IB_SERVER_CONFIG"] = "test"
+
+ yield
+
+ # Teardown
+ shutil.rmtree(tmp_dir)
+
+
+@pytest.fixture(name="testapp", scope="session")
+def fixture_testapp():
+ app = create_app("test")
+
+ yield app
+
+
+@pytest.fixture(name="client")
+def fixture_client(testapp: Quart) -> TestClientProtocol:
+ # Needs beets_lib to be initialized for environment variables
+ return testapp.test_client()
+
+
+@pytest.fixture(name="runner")
+def fixture_runner(app):
+ return app.test_cli_runner()
+
+
+# ----------------------------- Database fixtures ---------------------------- #
+# Both for our and the beets database
+
+
+@pytest.fixture(name="db_session_factory")
+def db_session_factory(
+ testapp,
+) -> Callable[..., _GeneratorContextManager[Session, None, None]]:
+ from beets_flask.database import db_session_factory
+
+ return db_session_factory
+
+
+@pytest.fixture(name="db_session")
+def db_session(db_session_factory):
+ with db_session_factory() as session:
+ yield session
+
+
+@pytest.fixture(scope="function")
+def beets_lib() -> Generator[BeetsLibrary, None, None]:
+ import beets.library
+
+ from beets_flask.config.beets_config import refresh_config
+
+ lib = beets.library.Library(path=os.environ["BEETSDIR"] + "/library.db")
+
+ refresh_config()
+
+ # Copy test audio data
+ source = Path(__file__).parent / "data" / "audio"
+ dest = Path(os.environ["HOME"]) / "audio"
+ Path(dest).mkdir(exist_ok=True)
+
+ shutil.copytree(source, dest, dirs_exist_ok=True)
+
+ yield lib
+
+ os.remove(os.environ["BEETSDIR"] + "/library.db")
+
+ # Clean up the copied files
+ shutil.rmtree(dest)
+
+
+def beets_lib_item(**kwargs):
+ """
+ Usage:
+
+ beets_lib.add(item(title="the title", artist="the artist", album="the album"))
+ """
+ import beets.library
+
+ default_kwargs = dict(
+ title="the title",
+ artist="the artist",
+ albumartist="the album artist",
+ album="the album",
+ genre="the genre",
+ lyricist="the lyricist",
+ composer="the composer",
+ arranger="the arranger",
+ grouping="the grouping",
+ year=1,
+ month=2,
+ day=3,
+ track=4,
+ tracktotal=5,
+ disc=6,
+ disctotal=7,
+ lyrics="the lyrics",
+ comments="the comments",
+ path=os.environ["HOME"] + "/audio/test.mp3",
+ )
+ default_kwargs.update(kwargs)
+
+ i = beets.library.Item(
+ None,
+ **default_kwargs,
+ )
+ return i
+
+
+def beets_lib_album(**kwargs):
+ """
+ Usage:
+
+ beets_lib.add(album(title="the title", artist="the artist"))
+ """
+ import beets.library
+
+ default_kwargs = dict(
+ album="the album",
+ albumartist="the album artist",
+ genre="the genre",
+ year=1,
+ month=2,
+ day=3,
+ tracktotal=5,
+ disctotal=7,
+ lyrics="the lyrics",
+ comments="the comments",
+ artpath=os.environ["HOME"] + "/audio/cover.png",
+ )
+ default_kwargs.update(kwargs)
+
+ a = beets.library.Album(
+ db=None,
+ **default_kwargs,
+ )
+ return a
+
+
+# ---------------------------------- Mocking --------------------------------- #
+
+
+@pytest.fixture(autouse=True)
+def local_redis(monkeypatch):
+ """Mock all redis calls with rq
+ see https://python-rq.org/docs/testing/
+ see https://docs.pytest.org/en/7.1.x/how-to/monkeypatch.html#global-patch-example-preventing-requests-from-remote-operations
+ """
+
+ from fakeredis import FakeStrictRedis
+ from rq import Queue
+
+ log.debug("Mocking beets_flask.redis")
+ monkeypatch.setattr(
+ "beets_flask.redis.import_queue",
+ Queue("import", is_async=False, connection=FakeStrictRedis()),
+ )
+ monkeypatch.setattr(
+ "beets_flask.redis.preview_queue",
+ Queue("preview", is_async=False, connection=FakeStrictRedis()),
+ )
+ yield
+ log.debug("Unmocking beets_flask.redis")
+ monkeypatch.undo()
diff --git a/backend/tests/integration/test_flows.py b/backend/tests/integration/test_flows.py
index 7e72b907..eea475b7 100644
--- a/backend/tests/integration/test_flows.py
+++ b/backend/tests/integration/test_flows.py
@@ -1,1136 +1,1136 @@
-"""Import/Preview flow tests for the backend.
-
-These tests are designed to ensure that the import and preview flows work as expected. These
-flows may be triggered from the frontend by the users and we want to ensure that everything
-has a well defined path to follow.
-"""
-
-import pickle
-from abc import ABC
-from pathlib import Path
-from typing import Literal
-from unittest import mock
-
-import pytest
-from sqlalchemy import delete, func, select
-from sqlalchemy.orm import Session
-
-from beets_flask.database.models.states import (
- FolderInDb,
- SessionStateInDb,
-)
-from beets_flask.disk import Folder
-from beets_flask.importer.progress import FolderStatus, Progress
-from beets_flask.importer.session import (
- CandidateChoice,
- TaskIdMappingArg,
-)
-from beets_flask.importer.types import DuplicateAction
-from beets_flask.invoker.enqueue import (
- run_import_auto,
- run_import_bootleg,
- run_import_candidate,
- run_import_undo,
- run_preview,
- run_preview_add_candidates,
-)
-from beets_flask.server.websocket.status import FolderStatusUpdate
-from tests.mixins.database import IsolatedBeetsLibraryMixin, IsolatedDBMixin
-from tests.mixins.plugins import PluginEventsMixin
-from tests.unit.test_importer.conftest import (
- VALID_PATHS,
- album_path_absolute,
- use_mock_tag_album,
-)
-
-
-class SendStatusMockMixin(ABC):
- """
- Allows to test without a running websocket server for
- status updates in the invoker.
-
- Usage:
- ```
- class TestMyFeature(SendStatusMockMixin):
- def test_something(self):
- # add to clean db
- assert self.statuses == [SomeStatus]
- ```
- """
-
- # list[{path: str, hash: str, status: FolderStatus}]
- statuses: list[FolderStatusUpdate] = []
-
- async def send_status_update(self, status):
- """Mock the emit_status decorator"""
- self.statuses.append(status)
-
- # ??? due to class inheritance, scope="function" effectively becomes class.
- # What we found is that, as is now, we get a websocket that survives between
- # different test functions.
- @pytest.fixture(autouse=True, scope="function")
- def mock_status(self):
- """Mock the emit_status decorator"""
-
- with mock.patch(
- "beets_flask.server.websocket.status.send_status_update",
- self.send_status_update,
- ):
- yield
-
- # Unexpectetly, this does not reset the statuses after each test.
- # -> do it manually in the tests as needed.
- self.statuses = []
-
-
-class TestPreview(SendStatusMockMixin, IsolatedDBMixin, IsolatedBeetsLibraryMixin):
- """Test generating previews.
-
- Minimal test to ensure that the preview flow works as expected.
- - uses a valid album path
- - uses an archive file
- """
-
- @pytest.fixture(
- params=[
- VALID_PATHS[0],
- "1991.zip",
- ]
- )
- def path(self, request) -> Path:
- path = album_path_absolute(request.param)
- use_mock_tag_album(str(path))
- return path
-
- async def test_preview(
- self,
- db_session: Session,
- path,
- ):
- self.statuses = []
- self.reset_database()
-
- stmt = select(SessionStateInDb).order_by(SessionStateInDb.created_at.desc())
- assert db_session.execute(stmt).scalar() is None, (
- "Database should be empty before the test"
- )
-
- exc = await run_preview(
- "obsolete_hash_preview",
- str(path),
- group_albums=None,
- autotag=True,
- )
-
- assert exc is None, "Should not return an error"
-
- # Check that status was emitted correctly, we emit once before and once after run
- assert len(self.statuses) == 2
- assert self.statuses[0].status == FolderStatus.PREVIEWING
- assert self.statuses[1].status == FolderStatus.PREVIEWED
-
- # Check db contains the tagged folder
- stmt = select(SessionStateInDb)
- s_state_indb = db_session.execute(stmt).scalar()
-
- assert s_state_indb is not None
- assert s_state_indb.folder.full_path == str(path)
-
- # Check preview content is correct
- s_state_live = s_state_indb.to_live_state()
- assert s_state_live is not None
- assert s_state_live.folder_path == path
- assert len(s_state_live.task_states) == 1
-
- assert s_state_live.tasks[0].old_paths is None
- # old_paths should only be set after files were moved!
-
- t_state_live = s_state_live.task_states[0]
- assert t_state_live.progress == Progress.PREVIEW_COMPLETED
-
- for c in t_state_live.candidate_states:
- assert len(c.duplicate_ids) == 0, (
- "Should not have duplicates in empty library"
- )
-
- assert c._mapping is not None, "Candidate should have a mapping"
-
-
-class TestPreviewMultipleTasks(
- SendStatusMockMixin, IsolatedDBMixin, IsolatedBeetsLibraryMixin
-):
- """Test generating previews with multiple tasks."""
-
- @pytest.fixture()
- def path(self) -> Path:
- path = album_path_absolute("multi_flat")
- use_mock_tag_album(str(path))
- return path
-
- @pytest.mark.parametrize(
- "group_albums, expected_tasks",
- [
- (True, 2), # Grouped albums should result in two tasks
- (False, 1), # Flat albums should result in four tasks
- ],
- )
- @pytest.mark.parametrize("autotag", [True, False])
- async def test_preview_grouped(
- self,
- db_session: Session,
- path: Path,
- group_albums: bool,
- expected_tasks: int,
- autotag: bool,
- ):
- self.statuses = []
- self.reset_database()
-
- stmt = select(SessionStateInDb).order_by(SessionStateInDb.created_at.desc())
- assert db_session.execute(stmt).scalar() is None, (
- "Database should be empty before the test"
- )
-
- exc = await run_preview(
- "obsolete_hash_preview",
- str(path),
- group_albums=group_albums,
- autotag=autotag,
- )
-
- assert exc is None, "Should not return an error"
-
- assert len(self.statuses) == 2
- assert self.statuses[0].status == FolderStatus.PREVIEWING
- assert self.statuses[1].status == FolderStatus.PREVIEWED
-
- # Check only one session in db (we expect two tasks, in one session)
- stmt = select(func.count()).select_from(SessionStateInDb)
- num_sessions = db_session.execute(stmt).scalar()
- assert num_sessions == 1, "Should have one session in the database"
-
- # Check db contains the tagged folder
- s_state_indb = db_session.execute(select(SessionStateInDb)).scalar()
-
- assert s_state_indb is not None
- assert s_state_indb.folder.full_path == str(path)
-
- # Check preview content is correct
- s_state_live = s_state_indb.to_live_state()
- assert s_state_live is not None
- assert s_state_live.folder_path == path
- assert len(s_state_live.task_states) == expected_tasks
- assert s_state_live.tasks[0].old_paths is None
-
- for t_state_live in s_state_live.task_states:
- assert t_state_live.progress == Progress.PREVIEW_COMPLETED
-
- for c in t_state_live.candidate_states:
- assert len(c.duplicate_ids) == 0, (
- "Should not have duplicates in empty library"
- )
-
-
-class TestImportBest(SendStatusMockMixin, IsolatedDBMixin, IsolatedBeetsLibraryMixin):
- """Test a typical import using the best candidate.
-
- This should be the most common case, i.e. the candidate looks good!
-
- The flow is as follows:
- - Generate Preview
- - Import best candidate
- - Trying to reimport same session should fail
- - Trying to import another session with duplicate candidate should fail
- - Revert import should work
- """
-
- @pytest.fixture()
- def path(self) -> Path:
- path = album_path_absolute(VALID_PATHS[0])
- use_mock_tag_album(str(path))
- return path
-
- def check_mapping_consistency(self, db_session: Session):
- """
- check that the mapping always goes from 0 to x where x is the amount of tracks.
-
- since we query from online data, mappinngs might not be fully reproducible.
- """
-
- stmt = select(SessionStateInDb)
- s_states_indb = db_session.execute(stmt).scalars()
-
- for s in s_states_indb:
- for t in s.to_live_state().task_states:
- for c in t.candidate_states:
- assert c.mapping in [{0: x} for x in range(0, c.num_tracks)]
-
- return True
-
- async def test_preview(self, db_session: Session, path: Path):
- """This is only used to set up the initial preview state for the
- following tests."""
-
- stmt = select(SessionStateInDb).order_by(SessionStateInDb.created_at.desc())
- assert db_session.execute(stmt).scalar() is None, (
- "Database should be empty before the test"
- )
-
- await run_preview(
- "obsolete_hash_preview",
- str(path),
- group_albums=None,
- autotag=None,
- )
-
- # Check if mapping is set correctly
- assert self.check_mapping_consistency(db_session)
-
- async def test_add_candidates(self, db_session: Session, path: Path):
- """Test the add candidates of the import process.
-
- This should be done in the preview step, but we want to test
- it separately to make sure that the candidates are found correctly.
- """
-
- stmt = select(SessionStateInDb).order_by(SessionStateInDb.created_at.desc())
- s_state_indb = db_session.execute(stmt).scalar()
-
- assert s_state_indb is not None
- assert len(s_state_indb.tasks) == 1
-
- id_99_red_balloons = "30fd0c55-a75d-4881-ade9-ae5a51f1ba86"
- exc = await run_preview_add_candidates(
- "obsolete_hash_preview",
- str(path),
- search={
- "*": {
- "search_ids": [
- id_99_red_balloons,
- ], # Nena 99 Red Balloons
- "search_artist": None,
- "search_album": None,
- }
- },
- )
- assert exc is None, "Should not return an error"
-
- stmt = select(SessionStateInDb)
- s_state_indb = db_session.execute(stmt).scalar()
- assert s_state_indb is not None
- assert s_state_indb.folder.full_path == str(path)
-
- # candidates now contain the search results
- s_state_live = s_state_indb.to_live_state()
- assert len(s_state_live.task_states) == 1
- t_state_live = s_state_live.task_states[0]
- album_ids = [c.match.info.album_id for c in t_state_live.candidate_states]
- assert id_99_red_balloons in album_ids, "Should have added the new candidate"
-
- # Check if mapping is set correctly
- assert self.check_mapping_consistency(db_session)
-
- async def test_add_candidates_fails(self, db_session: Session, path: Path):
- """Test that an exception is raised if candidate lookup fails (returns no results)."""
-
- stmt = select(SessionStateInDb).order_by(SessionStateInDb.created_at.desc())
- s_state_indb = db_session.execute(stmt).scalar()
-
- assert s_state_indb is not None
- assert len(s_state_indb.tasks) == 1
- test_exc = {"type": "test_value"}
- s_state_indb.exc = pickle.dumps(test_exc)
- db_session.commit()
-
- exc = await run_preview_add_candidates(
- "obsolete_hash_preview",
- str(path),
- search={
- "*": {
- "search_ids": [
- "non_existing_id",
- ], # Nena 99 Red Balloons
- "search_artist": None,
- "search_album": None,
- }
- },
- )
- assert exc is not None, "Should return an error"
- assert exc["type"] == "NoCandidatesFoundException"
-
- # Refetch state from db
- stmt = select(SessionStateInDb).order_by(SessionStateInDb.created_at.desc())
- s_state_indb = db_session.execute(stmt).scalar()
- assert s_state_indb is not None
- assert s_state_indb.exception is not None, "Exception should be set"
- assert s_state_indb.exception == test_exc, "Exception should be unchanged"
-
- # Check if mapping is still set correctly
- assert self.check_mapping_consistency(db_session)
-
- async def test_add_candidates_cleared(self, db_session: Session, path: Path):
- """Tests that candidates can be added after a NoCandidatesFoundException
- and the exception is cleared"""
-
- stmt = select(SessionStateInDb).order_by(SessionStateInDb.created_at.desc())
- s_state_indb = db_session.execute(stmt).scalar()
-
- assert s_state_indb is not None
- assert len(s_state_indb.tasks) == 1
- s_state_indb.exc = pickle.dumps({"type": "NoCandidatesFoundException"})
- # commit
- db_session.commit()
-
- id_99_red_balloons = "30fd0c55-a75d-4881-ade9-ae5a51f1ba86"
- exc = await run_preview_add_candidates(
- "obsolete_hash_preview",
- str(path),
- search={
- "*": {
- "search_ids": [
- id_99_red_balloons,
- ], # Nena 99 Red Balloons
- "search_artist": None,
- "search_album": None,
- }
- },
- )
- assert exc is None, "Should not return an error"
-
- # Refetch state from db
- stmt = select(SessionStateInDb).order_by(SessionStateInDb.created_at.desc())
- s_state_indb = db_session.execute(stmt).scalar()
- assert s_state_indb is not None
- assert s_state_indb.exception is None, "Exception should have been cleared"
-
- async def test_regenerate_preview(self, db_session: Session, path: Path):
- """Test the regeneration of the preview of the import process.
-
- We start from an earlier preview, and want to make sure that
- the new preview creates a state with a higher folder_revision,
- keeping the old one in tact.
- """
- f = Folder.from_path(path)
-
- exc = await run_preview(
- f.hash,
- str(path),
- group_albums=None,
- autotag=None,
- )
- assert exc is None, "Should not return an error"
-
- stmt = select(SessionStateInDb.folder_revision)
- revisions = db_session.execute(stmt).scalars().all()
-
- assert 0 in revisions
- assert 1 in revisions
- assert len(revisions) == 2, "Should have two revisions in the database"
-
- # clean up the second session
- stmt = delete(SessionStateInDb).where(
- SessionStateInDb.folder_hash == f.hash,
- SessionStateInDb.folder_revision == 1,
- )
- db_session.execute(stmt)
- db_session.commit()
-
- # Check if mapping is set correctly
- assert self.check_mapping_consistency(db_session)
-
- async def test_import(self, db_session: Session, path: Path):
- """
- Test the import of the tagged folder.
-
- The preview of the previous test should still exist in the database,
- because we reset the db via IsolatedDBMixin on scope=class
- """
-
- stmt = select(func.count()).select_from(SessionStateInDb)
- assert db_session.execute(stmt).scalar() == 1, (
- "Database should contain the one preview session state from the previous test"
- )
-
- # Check if mapping is set correctly
- assert self.check_mapping_consistency(db_session)
-
- self.statuses = []
- exc = await run_import_candidate(
- "obsolete_hash_import",
- str(path),
- candidate_ids=None, # None uses best match
- duplicate_actions=None, # None uses config
- )
- assert exc is None, "Should not return an error"
-
- # Check if mapping is still correctly after import
- assert self.check_mapping_consistency(db_session)
-
- # Check that status was emitted correctly, we emit once before and once after run
- assert len(self.statuses) == 2
- assert self.statuses[0].status == FolderStatus.IMPORTING
- assert self.statuses[1].status == FolderStatus.IMPORTED
-
- # Check db still contains one tagged folder
- stmt = select(SessionStateInDb)
- s_state_indb = db_session.execute(stmt).scalar()
-
- assert s_state_indb is not None
- assert s_state_indb.folder.full_path == str(path)
-
- # Check preview content is correct
- s_state_live = s_state_indb.to_live_state()
- assert s_state_live is not None
- assert s_state_live.folder_path == path
- assert len(s_state_live.task_states) == 1
- assert s_state_live.tasks[0].old_paths is not None
-
- t_state_live = s_state_live.task_states[0]
- assert t_state_live.progress == Progress.IMPORT_COMPLETED
-
- for c in t_state_live.candidate_states:
- assert len(c.duplicate_ids) == 0, (
- "Should not have duplicates in empty library"
- )
- assert t_state_live.chosen_candidate_state_id is not None
-
- # Check that we have the items in the beets lib
- albums = self.beets_lib.albums()
- assert len(albums) == 1, "Should have imported one album"
- items = albums[0].items()
- assert len(items) == 1, "Should have imported one item"
-
- # gui import ids are set
- album = albums[0]
- assert hasattr(album, "gui_import_id"), "Album should have gui_import_id"
- assert album.gui_import_id is not None, "Album should have gui_import_id"
-
- async def test_reimport_fails(self, db_session: Session, path: Path):
- """Reimport should fail if the state is already imported.
-
- We use errors as values here so we need to check the return value
- """
- stmt = select(func.count()).select_from(SessionStateInDb)
- assert db_session.execute(stmt).scalar() == 1, (
- "Database should contain the one preview session state from the previous test"
- )
-
- self.statuses = []
-
- exc = await run_import_candidate(
- "obsolete_hash_import",
- str(path),
- candidate_ids=None, # None uses best match
- duplicate_actions={"*": "ask"},
- )
-
- assert exc is not None
- assert exc["message"] == "Cannot redo imports. Try undo and/or retag!"
-
- assert len(self.statuses) == 2
- assert self.statuses[0].status == FolderStatus.IMPORTING
- assert self.statuses[1].status == FolderStatus.FAILED
-
- async def test_duplicate_import_fails(self, path: Path):
- """
- Duplicates should normally only happen if you import the same
- items from a different folder.
-
- We use the same items but a different folder here ;) A bit
- hacky but works for our purpose.
- """
-
- # Check item already in beets library
- albums = self.beets_lib.albums()
- assert len(albums) == 1, "Should have imported one album"
-
- await run_preview(
- "obsolete_hash_preview",
- str(path / "Chant [SINGLE]"),
- group_albums=None,
- autotag=None,
- )
-
- exc = await run_import_candidate(
- "obsolete_hash_import",
- str(path / "Chant [SINGLE]"),
- candidate_ids=None, # None uses best match
- duplicate_actions={"*": "ask"}, # ask raises on duplicate
- )
-
- # FIXME: We might want to raise our own exception here
- assert exc is not None
- assert exc["type"] == "DuplicateException"
-
- async def test_undo(self, db_session: Session, path: Path):
- """Test the undo of the import process.
-
- This should remove the items from the beets library and
- set the progress back to PREVIEW_COMPLETED. Also the disk
- items should be removed/moved back.
- """
-
- f = Folder.from_path(path)
-
- items = self.beets_lib.items()
- item = items[0]
- assert item is not None, "Should have imported at least one item for this test."
- imported_path = Path(item.path.decode("utf-8"))
-
- self.statuses = []
- exc = await run_import_undo(
- f.hash,
- str(path),
- delete_files=True,
- )
-
- assert exc is None
- assert len(self.statuses) == 2
- assert self.statuses[0].status == FolderStatus.DELETING
- assert self.statuses[1].status == FolderStatus.DELETED
-
- items = self.beets_lib.items()
- assert len(items) == 0, "Should have removed all items from beets library"
- assert not imported_path.exists(), "Should have removed the imported files"
-
- async def test_undo_fails(self, db_session: Session, path: Path):
- """If the session is not in a imported state we should fail."""
- f = Folder.from_path(path)
-
- exc = await run_import_undo(
- f.hash,
- str(path),
- delete_files=True,
- )
-
- assert exc is not None
- assert "Cannot undo if never imported" in exc["message"]
-
- async def test_reimport_after_undo(self, db_session: Session, path: Path):
- # Case two: Import session valid but no beets items
- exc = await run_import_candidate(
- "obsolete_hash_import",
- str(path),
- candidate_ids=None, # None uses best match
- duplicate_actions=None, # None uses config
- )
- assert exc is None
-
- # Check that we have the items in the beets lib
- albums = self.beets_lib.albums()
- assert len(albums) == 1, "Should have imported one album"
- items = albums[0].items()
- assert len(items) == 1, "Should have imported one item"
-
- # Check files have been imported
- imported_path = Path(items[0].path.decode("utf-8"))
- assert imported_path.exists(), "Should have imported the files"
- assert imported_path.is_file(), "Should have imported the files"
-
- @pytest.mark.parametrize("duplicate_action", ["skip", "merge", "remove", "keep"])
- async def test_duplicate_with_action(
- self, db_session: Session, path: Path, duplicate_action
- ):
- """Test the duplicate action with a different action.
-
- This should not return an error but just do the action (if not ask).
- """
-
- # Check item already in beets library
- p = str(path / "Chant [SINGLE]")
- albums = self.beets_lib.albums()
- assert len(albums) == 1, "Should have imported one album"
-
- # Reset session state to PREVIEW_COMPLETED to allow to reuse it on
- # multiple runs of this test
- stmt = (
- select(SessionStateInDb).join(FolderInDb).where(FolderInDb.full_path == p)
- )
- session_state = db_session.execute(stmt).scalar()
- assert session_state is not None
-
- # Reset progress to PREVIEW_COMPLETED
- for task in session_state.tasks:
- task.progress = Progress.PREVIEW_COMPLETED
- db_session.commit()
-
- self.statuses = []
- exc = await run_import_candidate(
- "obsolete_hash_import",
- p,
- candidate_ids=None, # None uses best match
- duplicate_actions={"*": duplicate_action},
- )
-
- # Shouldn't return an error
- assert exc is None
- assert len(self.statuses) == 2
- assert self.statuses[0].status == FolderStatus.IMPORTING
- assert self.statuses[1].status == FolderStatus.IMPORTED
-
- # After import we should not have a duplicate id anymore
- session_state = db_session.execute(stmt).scalar()
- assert session_state is not None
- live_state = session_state.to_live_state()
- assert live_state is not None
-
- for task in live_state.task_states:
- chosen_candidate = task.chosen_candidate_state
- assert chosen_candidate is not None
- assert len(chosen_candidate.duplicate_ids) == 0, (
- "Should not have duplicates after import"
- )
-
- async def test_undo_with_missing_beets_items(self, db_session: Session, path: Path):
- f = Folder.from_path(path)
- items = self.beets_lib.items()
-
- with self.beets_lib.transaction() as tx:
- for item in items:
- item.remove()
-
- exc = await run_import_undo(
- f.hash,
- str(path),
- delete_files=True,
- )
-
- assert exc is not None
- assert exc["type"] == "IntegrityException"
-
-
-class TestImportAuto(SendStatusMockMixin, IsolatedDBMixin, IsolatedBeetsLibraryMixin):
- """Test that the preview + threshold-dependent import works.
-
- The flow is as follows:
- - Generate Preview
- - Check treshold
- - Import best candidate, but only when better than specified distance
- """
-
- @pytest.fixture()
- def path(self) -> Path:
- path = album_path_absolute(VALID_PATHS[0])
- use_mock_tag_album(str(path))
- return path
-
- async def test_import_auto_accept(self, db_session: Session, path: Path):
- """
- Check that the import either fails or goes through, depending on the threshold.
- """
- stmt = select(SessionStateInDb).order_by(SessionStateInDb.created_at.desc())
- assert db_session.execute(stmt).scalar() is None, (
- "Database should be empty before the test"
- )
-
- self.statuses = []
-
- await run_preview(
- "obsolete_hash_preview",
- str(path),
- group_albums=None,
- autotag=None,
- )
-
- await run_import_auto(
- "obsolete_hash_import_auto",
- str(path),
- import_threshold=0.0,
- duplicate_actions={"*": "remove"},
- )
-
- assert len(self.statuses) == 4
- assert self.statuses[2].status == FolderStatus.IMPORTING
- assert self.statuses[3].status == FolderStatus.FAILED
- assert len(self.beets_lib.albums()) == 0
-
- await run_import_auto(
- "obsolete_hash_import_auto",
- str(path),
- import_threshold=1.0,
- duplicate_actions={"*": "remove"},
- )
-
- assert len(self.statuses) == 6
- assert self.statuses[4].status == FolderStatus.IMPORTING
- assert self.statuses[5].status == FolderStatus.IMPORTED
- assert len(self.beets_lib.albums()) == 1
-
-
-class TestImportAutoFails(
- SendStatusMockMixin, IsolatedDBMixin, IsolatedBeetsLibraryMixin
-):
- @pytest.fixture()
- def path(self) -> Path:
- path = album_path_absolute(VALID_PATHS[0])
- use_mock_tag_album(str(path))
- return path
-
- async def test_import_auto_fails(self, db_session: Session, path: Path):
- stmt = select(SessionStateInDb).order_by(SessionStateInDb.created_at.desc())
- assert db_session.execute(stmt).scalar() is None, (
- "Database should be empty before the test"
- )
-
- self.statuses = []
-
- await run_preview(
- "obsolete_hash_preview",
- str(path),
- group_albums=None,
- autotag=None,
- )
-
- exc = await run_import_auto(
- "obsolete_hash_import_auto",
- str(path),
- import_threshold=-1.0,
- duplicate_actions={"*": "remove"},
- )
- assert exc is not None, f"Should return an error {exc}"
-
- assert len(self.statuses) == 4
- assert self.statuses[2].status == FolderStatus.IMPORTING
- assert self.statuses[3].status == FolderStatus.FAILED
- assert len(self.beets_lib.albums()) == 0 # one from the previous test
-
- stmt = select(SessionStateInDb).order_by(SessionStateInDb.created_at.desc())
- s_state_indb = db_session.execute(stmt).scalar()
- assert s_state_indb is not None
- assert s_state_indb.exception is not None
-
- # After a failed import, we should be able to import again manually
- exc = await run_import_candidate(
- "obsolete_hash_import",
- str(path),
- candidate_ids=None, # None uses best match
- duplicate_actions={"*": "remove"},
- )
- assert exc is None, "Should not return an error"
-
- # The database session state should not contain an exception anymore
- stmt = select(SessionStateInDb).order_by(SessionStateInDb.created_at.desc())
- s_state_indb = db_session.execute(stmt).scalar()
- assert s_state_indb is not None
- assert s_state_indb.exception is None, "Exception should have been cleared"
-
-
-class TestChooseCandidatesSingleTask(
- SendStatusMockMixin, IsolatedDBMixin, IsolatedBeetsLibraryMixin
-):
- """Test a typical import using a choosen candidate."""
-
- @pytest.fixture()
- def path_single_task(self) -> Path:
- path = album_path_absolute(VALID_PATHS[0])
- use_mock_tag_album(str(path))
- return path
-
- async def test_choose_candidates(
- self,
- db_session: Session,
- path_single_task: Path,
- ):
- """Test the import of the tagged folder using a candidate id (single task in session)"""
-
- exc = await run_preview(
- "obsolete_hash_preview",
- str(path_single_task),
- group_albums=None,
- autotag=None,
- )
- assert exc is None, "Should not return an error"
-
- # Check db contains the tagged folder
- stmt = select(SessionStateInDb)
- s_state_indb = db_session.execute(stmt).scalar()
-
- assert s_state_indb is not None
- assert s_state_indb.folder.full_path == str(path_single_task)
- assert len(s_state_indb.tasks) == 1
-
- choosen_candidate = s_state_indb.tasks[0].candidates[-2]
-
- exc = await run_import_candidate(
- "obsolete_hash_import",
- str(path_single_task),
- candidate_ids={"*": choosen_candidate.id},
- duplicate_actions=None, # None uses config
- )
- assert exc is None, "Should not return an error"
-
- # Check db still contains one tagged folder
- stmt = select(SessionStateInDb)
- s_state_indb = db_session.execute(stmt).scalar()
- assert s_state_indb is not None
- assert s_state_indb.folder.full_path == str(path_single_task)
-
- # Check choosen candidate is the one we imported
- s_state_live = s_state_indb.to_live_state()
- assert s_state_live is not None
- assert s_state_live.folder_path == path_single_task
- assert len(s_state_live.task_states) == 1
- assert s_state_live.tasks[0].old_paths is not None
- t_state_live = s_state_live.task_states[0]
- assert t_state_live.progress == Progress.IMPORT_COMPLETED
- assert t_state_live.chosen_candidate_state is not None
- assert t_state_live.chosen_candidate_state_id == choosen_candidate.id
-
-
-class TestMultipleTasks(
- SendStatusMockMixin, IsolatedDBMixin, IsolatedBeetsLibraryMixin
-):
- """Test a typical import of a multiple tasks using choosen candidates."""
-
- @pytest.fixture()
- def path_multiple_tasks(self) -> Path:
- path = album_path_absolute("multi")
- use_mock_tag_album(str(path))
- return path
-
- async def test_choose_candidates_multiple_tasks(
- self,
- db_session: Session,
- path_multiple_tasks: Path,
- ):
- """Test the import of the tagged folder."""
-
- exc = await run_preview(
- "obsolete_hash_preview",
- str(path_multiple_tasks),
- group_albums=None,
- autotag=None,
- )
- assert exc is None, "Should not return an error"
-
- # Check db contains the tagged folder with multiple tasks
- stmt = select(SessionStateInDb)
- s_state_indb: SessionStateInDb | None = db_session.execute(stmt).scalar()
- assert s_state_indb is not None
- assert len(s_state_indb.tasks) > 1, "Should have multiple tasks"
-
- # For each task, choose a different candidate
-
- candidates: TaskIdMappingArg[CandidateChoice] = {}
- assert candidates is not None
- for task in s_state_indb.tasks:
- print(task.paths)
- print([c.metadata for c in task.candidates])
- assert len(task.candidates) > 2, "Should have candidates"
- candidates[task.id] = task.candidates[-2].id
-
- # Check that we have the same number of candidates as tasks
- assert len(candidates) == len(s_state_indb.tasks), (
- "Should have same number of candidates as tasks"
- )
-
- exc = await run_import_candidate(
- "obsolete_hash_import",
- str(path_multiple_tasks),
- candidate_ids=candidates,
- duplicate_actions=None, # None uses config
- )
- assert exc is None, "Should not return an error"
-
- # Check db still contains one tagged folder
- stmt = select(SessionStateInDb)
- s_state_indb = db_session.execute(stmt).scalar()
- assert s_state_indb is not None
- assert s_state_indb.folder.full_path == str(path_multiple_tasks)
- assert len(s_state_indb.tasks) > 1, "Should have multiple tasks"
-
- @pytest.mark.parametrize("duplicate_action", ["skip", "merge", "remove", "keep"])
- async def test_duplicate_action(
- self,
- db_session: Session,
- path_multiple_tasks: Path,
- duplicate_action: Literal["skip", "merge", "remove", "keep"],
- ):
- """Test the import of the tagged folder with duplicate action."""
-
- # Check db contains the tagged folder with multiple tasks
- stmt = select(SessionStateInDb)
- s_state_indb = db_session.execute(stmt).scalar()
- assert s_state_indb is not None
- assert len(s_state_indb.tasks) > 1, "Should have multiple tasks"
-
- # Reset session state to PREVIEW_COMPLETED to allow to reuse it on
- # multiple runs of this test
- stmt = (
- select(SessionStateInDb)
- .join(FolderInDb)
- .where(FolderInDb.full_path == str(path_multiple_tasks))
- )
- session_state = db_session.execute(stmt).scalar()
- assert session_state is not None
- # Reset progress to PREVIEW_COMPLETED
- for task in session_state.tasks:
- task.progress = Progress.PREVIEW_COMPLETED
- db_session.commit()
-
- # For each task, choose a different candidate and duplicate action
- duplicate_actions: TaskIdMappingArg[DuplicateAction] = {}
- candidates: TaskIdMappingArg[CandidateChoice] = {}
- assert candidates is not None
- assert duplicate_actions is not None
-
- for task in s_state_indb.tasks:
- assert len(task.candidates) > 2, "Should have candidates"
- candidates[task.id] = task.candidates[-2].id
- duplicate_actions[task.id] = duplicate_action
-
- # Check that we have the same number of candidates as tasks
- assert len(candidates) == len(s_state_indb.tasks), (
- "Should have same number of candidates as tasks"
- )
-
- exc = await run_import_candidate(
- "obsolete_hash_import",
- str(path_multiple_tasks),
- candidate_ids=candidates,
- duplicate_actions=duplicate_actions,
- )
- assert exc is None, "Should not return an error"
-
-
-class TestPluginEvents(
- SendStatusMockMixin, IsolatedDBMixin, IsolatedBeetsLibraryMixin, PluginEventsMixin
-):
- """Test that the plugin events are triggered correctly.
-
- This is important to maintain compatibility with beets plugins that
- expect certain events to be triggered during the import/tag process.
- """
-
- @pytest.fixture()
- def path(self) -> Path:
- path = album_path_absolute(VALID_PATHS[0])
- use_mock_tag_album(str(path))
- return path
-
- async def test_preview_events(self, db_session: Session, path: Path):
- self.events = []
-
- await run_preview(
- "obsolete_hash_preview",
- str(path),
- group_albums=None,
- autotag=None,
- )
-
- assert "import_begin" in self.events[0]
- assert "import_task_created" in self.events[1]
- assert "import_task_start" in self.events[2]
- assert len(self.events) == 3
-
- async def test_import_auto_events(self, db_session: Session, path: Path):
- self.events = []
-
- exc = await run_import_auto(
- "obsolete_hash_import_auto",
- str(path),
- import_threshold=1.0,
- duplicate_actions={"*": "remove"},
- )
- assert exc is None, "Should not return an error"
-
- assert "import_begin" == self.events[0]
- # TODO: Does not trigger import_task_created and import_task_start
- assert "import_task_before_choice" in self.events[1]
- assert "import_task_choice" in self.events[2]
- assert "import_task_apply" in self.events[3]
- # TODO: Seems like quite some database change operations are done here
- assert "cli_exit" in self.events[-1]
-
- async def test_undo_events(self, db_session: Session, path: Path):
- self.events = []
-
- exc = await run_import_undo(
- "obsolete_hash_import",
- str(path),
- delete_files=True,
- )
- assert exc is None, "Should not return an error"
-
- # TODO: Does not trigger started events
- assert "item_removed" in self.events
- assert "album_removed" in self.events
- assert "cli_exit" == self.events[-1]
-
-
-class TestImportAsis(SendStatusMockMixin, IsolatedDBMixin, IsolatedBeetsLibraryMixin):
- """Test a typical import using the asis candidate.
-
- We have an extra test for this as the asis candidate is a bit special,
- it is generated by us and uses the original file metadata. Thus just
- to be sure we test it separately.
-
- The flow is as follows:
- - Generate Preview
- - Import asis candidate
- - Trying to reimport same session should fail
- - Trying to import another session with duplicate candidate should fail
- """
-
- @pytest.mark.skip(reason="Implement")
- def test_import_asis(self, db_session: Session, path: Path):
- raise NotImplementedError("Implement me")
-
-
-class TestImportCandidate(
- SendStatusMockMixin, IsolatedDBMixin, IsolatedBeetsLibraryMixin
-):
- """Test a typical import using the specific candidate.
-
- The flow is as follows:
- - Generate Preview
- - Import specific candidate
- - Trying to reimport same session should fail
- - Trying to import another session with duplicate candidate should fail
- """
-
- @pytest.mark.skip(reason="Implement")
- def test_import_candidate(self, db_session: Session, path: Path):
- raise NotImplementedError("Implement me")
-
-
-class TestImportBootleg(
- SendStatusMockMixin, IsolatedDBMixin, IsolatedBeetsLibraryMixin
-):
- """Test that import without lookup works.
-
- The flow is as follows:
- - Import candidate asis
- """
-
- @pytest.fixture()
- def path(self) -> Path:
- path = album_path_absolute(VALID_PATHS[0])
- use_mock_tag_album(str(path))
- return path
-
- async def test_import_bootleg(self, db_session: Session, path: Path):
- """
- Check that the import goes through, no matter what.
- """
- self.statuses = []
- self.reset_database()
-
- stmt = select(SessionStateInDb).order_by(SessionStateInDb.created_at.desc())
- assert db_session.execute(stmt).scalar() is None, (
- "Database should be empty before the test"
- )
-
- self.statuses = []
-
- exc = await run_import_bootleg(
- "obsolete_hash_import_auto",
- str(path),
- )
-
- assert exc is None, "Should not return an error"
-
- assert len(self.statuses) == 2
- assert self.statuses[0].status == FolderStatus.IMPORTING
- assert self.statuses[1].status == FolderStatus.IMPORTED
- assert len(self.beets_lib.albums()) == 1
+"""Import/Preview flow tests for the backend.
+
+These tests are designed to ensure that the import and preview flows work as expected. These
+flows may be triggered from the frontend by the users and we want to ensure that everything
+has a well defined path to follow.
+"""
+
+import pickle
+from abc import ABC
+from pathlib import Path
+from typing import Literal
+from unittest import mock
+
+import pytest
+from sqlalchemy import delete, func, select
+from sqlalchemy.orm import Session
+
+from beets_flask.database.models.states import (
+ FolderInDb,
+ SessionStateInDb,
+)
+from beets_flask.disk import Folder
+from beets_flask.importer.progress import FolderStatus, Progress
+from beets_flask.importer.session import (
+ CandidateChoice,
+ TaskIdMappingArg,
+)
+from beets_flask.importer.types import DuplicateAction
+from beets_flask.invoker.enqueue import (
+ run_import_auto,
+ run_import_bootleg,
+ run_import_candidate,
+ run_import_undo,
+ run_preview,
+ run_preview_add_candidates,
+)
+from beets_flask.server.websocket.status import FolderStatusUpdate
+from tests.mixins.database import IsolatedBeetsLibraryMixin, IsolatedDBMixin
+from tests.mixins.plugins import PluginEventsMixin
+from tests.unit.test_importer.conftest import (
+ VALID_PATHS,
+ album_path_absolute,
+ use_mock_tag_album,
+)
+
+
+class SendStatusMockMixin(ABC):
+ """
+ Allows to test without a running websocket server for
+ status updates in the invoker.
+
+ Usage:
+ ```
+ class TestMyFeature(SendStatusMockMixin):
+ def test_something(self):
+ # add to clean db
+ assert self.statuses == [SomeStatus]
+ ```
+ """
+
+ # list[{path: str, hash: str, status: FolderStatus}]
+ statuses: list[FolderStatusUpdate] = []
+
+ async def send_status_update(self, status):
+ """Mock the emit_status decorator"""
+ self.statuses.append(status)
+
+ # ??? due to class inheritance, scope="function" effectively becomes class.
+ # What we found is that, as is now, we get a websocket that survives between
+ # different test functions.
+ @pytest.fixture(autouse=True, scope="function")
+ def mock_status(self):
+ """Mock the emit_status decorator"""
+
+ with mock.patch(
+ "beets_flask.server.websocket.status.send_status_update",
+ self.send_status_update,
+ ):
+ yield
+
+ # Unexpectetly, this does not reset the statuses after each test.
+ # -> do it manually in the tests as needed.
+ self.statuses = []
+
+
+class TestPreview(SendStatusMockMixin, IsolatedDBMixin, IsolatedBeetsLibraryMixin):
+ """Test generating previews.
+
+ Minimal test to ensure that the preview flow works as expected.
+ - uses a valid album path
+ - uses an archive file
+ """
+
+ @pytest.fixture(
+ params=[
+ VALID_PATHS[0],
+ "1991.zip",
+ ]
+ )
+ def path(self, request) -> Path:
+ path = album_path_absolute(request.param)
+ use_mock_tag_album(str(path))
+ return path
+
+ async def test_preview(
+ self,
+ db_session: Session,
+ path,
+ ):
+ self.statuses = []
+ self.reset_database()
+
+ stmt = select(SessionStateInDb).order_by(SessionStateInDb.created_at.desc())
+ assert db_session.execute(stmt).scalar() is None, (
+ "Database should be empty before the test"
+ )
+
+ exc = await run_preview(
+ "obsolete_hash_preview",
+ str(path),
+ group_albums=None,
+ autotag=True,
+ )
+
+ assert exc is None, "Should not return an error"
+
+ # Check that status was emitted correctly, we emit once before and once after run
+ assert len(self.statuses) == 2
+ assert self.statuses[0].status == FolderStatus.PREVIEWING
+ assert self.statuses[1].status == FolderStatus.PREVIEWED
+
+ # Check db contains the tagged folder
+ stmt = select(SessionStateInDb)
+ s_state_indb = db_session.execute(stmt).scalar()
+
+ assert s_state_indb is not None
+ assert s_state_indb.folder.full_path == str(path)
+
+ # Check preview content is correct
+ s_state_live = s_state_indb.to_live_state()
+ assert s_state_live is not None
+ assert s_state_live.folder_path == path
+ assert len(s_state_live.task_states) == 1
+
+ assert s_state_live.tasks[0].old_paths is None
+ # old_paths should only be set after files were moved!
+
+ t_state_live = s_state_live.task_states[0]
+ assert t_state_live.progress == Progress.PREVIEW_COMPLETED
+
+ for c in t_state_live.candidate_states:
+ assert len(c.duplicate_ids) == 0, (
+ "Should not have duplicates in empty library"
+ )
+
+ assert c._mapping is not None, "Candidate should have a mapping"
+
+
+class TestPreviewMultipleTasks(
+ SendStatusMockMixin, IsolatedDBMixin, IsolatedBeetsLibraryMixin
+):
+ """Test generating previews with multiple tasks."""
+
+ @pytest.fixture()
+ def path(self) -> Path:
+ path = album_path_absolute("multi_flat")
+ use_mock_tag_album(str(path))
+ return path
+
+ @pytest.mark.parametrize(
+ "group_albums, expected_tasks",
+ [
+ (True, 2), # Grouped albums should result in two tasks
+ (False, 1), # Flat albums should result in four tasks
+ ],
+ )
+ @pytest.mark.parametrize("autotag", [True, False])
+ async def test_preview_grouped(
+ self,
+ db_session: Session,
+ path: Path,
+ group_albums: bool,
+ expected_tasks: int,
+ autotag: bool,
+ ):
+ self.statuses = []
+ self.reset_database()
+
+ stmt = select(SessionStateInDb).order_by(SessionStateInDb.created_at.desc())
+ assert db_session.execute(stmt).scalar() is None, (
+ "Database should be empty before the test"
+ )
+
+ exc = await run_preview(
+ "obsolete_hash_preview",
+ str(path),
+ group_albums=group_albums,
+ autotag=autotag,
+ )
+
+ assert exc is None, "Should not return an error"
+
+ assert len(self.statuses) == 2
+ assert self.statuses[0].status == FolderStatus.PREVIEWING
+ assert self.statuses[1].status == FolderStatus.PREVIEWED
+
+ # Check only one session in db (we expect two tasks, in one session)
+ stmt = select(func.count()).select_from(SessionStateInDb)
+ num_sessions = db_session.execute(stmt).scalar()
+ assert num_sessions == 1, "Should have one session in the database"
+
+ # Check db contains the tagged folder
+ s_state_indb = db_session.execute(select(SessionStateInDb)).scalar()
+
+ assert s_state_indb is not None
+ assert s_state_indb.folder.full_path == str(path)
+
+ # Check preview content is correct
+ s_state_live = s_state_indb.to_live_state()
+ assert s_state_live is not None
+ assert s_state_live.folder_path == path
+ assert len(s_state_live.task_states) == expected_tasks
+ assert s_state_live.tasks[0].old_paths is None
+
+ for t_state_live in s_state_live.task_states:
+ assert t_state_live.progress == Progress.PREVIEW_COMPLETED
+
+ for c in t_state_live.candidate_states:
+ assert len(c.duplicate_ids) == 0, (
+ "Should not have duplicates in empty library"
+ )
+
+
+class TestImportBest(SendStatusMockMixin, IsolatedDBMixin, IsolatedBeetsLibraryMixin):
+ """Test a typical import using the best candidate.
+
+ This should be the most common case, i.e. the candidate looks good!
+
+ The flow is as follows:
+ - Generate Preview
+ - Import best candidate
+ - Trying to reimport same session should fail
+ - Trying to import another session with duplicate candidate should fail
+ - Revert import should work
+ """
+
+ @pytest.fixture()
+ def path(self) -> Path:
+ path = album_path_absolute(VALID_PATHS[0])
+ use_mock_tag_album(str(path))
+ return path
+
+ def check_mapping_consistency(self, db_session: Session):
+ """
+ check that the mapping always goes from 0 to x where x is the amount of tracks.
+
+ since we query from online data, mappinngs might not be fully reproducible.
+ """
+
+ stmt = select(SessionStateInDb)
+ s_states_indb = db_session.execute(stmt).scalars()
+
+ for s in s_states_indb:
+ for t in s.to_live_state().task_states:
+ for c in t.candidate_states:
+ assert c.mapping in [{0: x} for x in range(0, c.num_tracks)]
+
+ return True
+
+ async def test_preview(self, db_session: Session, path: Path):
+ """This is only used to set up the initial preview state for the
+ following tests."""
+
+ stmt = select(SessionStateInDb).order_by(SessionStateInDb.created_at.desc())
+ assert db_session.execute(stmt).scalar() is None, (
+ "Database should be empty before the test"
+ )
+
+ await run_preview(
+ "obsolete_hash_preview",
+ str(path),
+ group_albums=None,
+ autotag=None,
+ )
+
+ # Check if mapping is set correctly
+ assert self.check_mapping_consistency(db_session)
+
+ async def test_add_candidates(self, db_session: Session, path: Path):
+ """Test the add candidates of the import process.
+
+ This should be done in the preview step, but we want to test
+ it separately to make sure that the candidates are found correctly.
+ """
+
+ stmt = select(SessionStateInDb).order_by(SessionStateInDb.created_at.desc())
+ s_state_indb = db_session.execute(stmt).scalar()
+
+ assert s_state_indb is not None
+ assert len(s_state_indb.tasks) == 1
+
+ id_99_red_balloons = "30fd0c55-a75d-4881-ade9-ae5a51f1ba86"
+ exc = await run_preview_add_candidates(
+ "obsolete_hash_preview",
+ str(path),
+ search={
+ "*": {
+ "search_ids": [
+ id_99_red_balloons,
+ ], # Nena 99 Red Balloons
+ "search_artist": None,
+ "search_album": None,
+ }
+ },
+ )
+ assert exc is None, "Should not return an error"
+
+ stmt = select(SessionStateInDb)
+ s_state_indb = db_session.execute(stmt).scalar()
+ assert s_state_indb is not None
+ assert s_state_indb.folder.full_path == str(path)
+
+ # candidates now contain the search results
+ s_state_live = s_state_indb.to_live_state()
+ assert len(s_state_live.task_states) == 1
+ t_state_live = s_state_live.task_states[0]
+ album_ids = [c.match.info.album_id for c in t_state_live.candidate_states]
+ assert id_99_red_balloons in album_ids, "Should have added the new candidate"
+
+ # Check if mapping is set correctly
+ assert self.check_mapping_consistency(db_session)
+
+ async def test_add_candidates_fails(self, db_session: Session, path: Path):
+ """Test that an exception is raised if candidate lookup fails (returns no results)."""
+
+ stmt = select(SessionStateInDb).order_by(SessionStateInDb.created_at.desc())
+ s_state_indb = db_session.execute(stmt).scalar()
+
+ assert s_state_indb is not None
+ assert len(s_state_indb.tasks) == 1
+ test_exc = {"type": "test_value"}
+ s_state_indb.exc = pickle.dumps(test_exc)
+ db_session.commit()
+
+ exc = await run_preview_add_candidates(
+ "obsolete_hash_preview",
+ str(path),
+ search={
+ "*": {
+ "search_ids": [
+ "non_existing_id",
+ ], # Nena 99 Red Balloons
+ "search_artist": None,
+ "search_album": None,
+ }
+ },
+ )
+ assert exc is not None, "Should return an error"
+ assert exc["type"] == "NoCandidatesFoundException"
+
+ # Refetch state from db
+ stmt = select(SessionStateInDb).order_by(SessionStateInDb.created_at.desc())
+ s_state_indb = db_session.execute(stmt).scalar()
+ assert s_state_indb is not None
+ assert s_state_indb.exception is not None, "Exception should be set"
+ assert s_state_indb.exception == test_exc, "Exception should be unchanged"
+
+ # Check if mapping is still set correctly
+ assert self.check_mapping_consistency(db_session)
+
+ async def test_add_candidates_cleared(self, db_session: Session, path: Path):
+ """Tests that candidates can be added after a NoCandidatesFoundException
+ and the exception is cleared"""
+
+ stmt = select(SessionStateInDb).order_by(SessionStateInDb.created_at.desc())
+ s_state_indb = db_session.execute(stmt).scalar()
+
+ assert s_state_indb is not None
+ assert len(s_state_indb.tasks) == 1
+ s_state_indb.exc = pickle.dumps({"type": "NoCandidatesFoundException"})
+ # commit
+ db_session.commit()
+
+ id_99_red_balloons = "30fd0c55-a75d-4881-ade9-ae5a51f1ba86"
+ exc = await run_preview_add_candidates(
+ "obsolete_hash_preview",
+ str(path),
+ search={
+ "*": {
+ "search_ids": [
+ id_99_red_balloons,
+ ], # Nena 99 Red Balloons
+ "search_artist": None,
+ "search_album": None,
+ }
+ },
+ )
+ assert exc is None, "Should not return an error"
+
+ # Refetch state from db
+ stmt = select(SessionStateInDb).order_by(SessionStateInDb.created_at.desc())
+ s_state_indb = db_session.execute(stmt).scalar()
+ assert s_state_indb is not None
+ assert s_state_indb.exception is None, "Exception should have been cleared"
+
+ async def test_regenerate_preview(self, db_session: Session, path: Path):
+ """Test the regeneration of the preview of the import process.
+
+ We start from an earlier preview, and want to make sure that
+ the new preview creates a state with a higher folder_revision,
+ keeping the old one in tact.
+ """
+ f = Folder.from_path(path)
+
+ exc = await run_preview(
+ f.hash,
+ str(path),
+ group_albums=None,
+ autotag=None,
+ )
+ assert exc is None, "Should not return an error"
+
+ stmt = select(SessionStateInDb.folder_revision)
+ revisions = db_session.execute(stmt).scalars().all()
+
+ assert 0 in revisions
+ assert 1 in revisions
+ assert len(revisions) == 2, "Should have two revisions in the database"
+
+ # clean up the second session
+ stmt = delete(SessionStateInDb).where(
+ SessionStateInDb.folder_hash == f.hash,
+ SessionStateInDb.folder_revision == 1,
+ )
+ db_session.execute(stmt)
+ db_session.commit()
+
+ # Check if mapping is set correctly
+ assert self.check_mapping_consistency(db_session)
+
+ async def test_import(self, db_session: Session, path: Path):
+ """
+ Test the import of the tagged folder.
+
+ The preview of the previous test should still exist in the database,
+ because we reset the db via IsolatedDBMixin on scope=class
+ """
+
+ stmt = select(func.count()).select_from(SessionStateInDb)
+ assert db_session.execute(stmt).scalar() == 1, (
+ "Database should contain the one preview session state from the previous test"
+ )
+
+ # Check if mapping is set correctly
+ assert self.check_mapping_consistency(db_session)
+
+ self.statuses = []
+ exc = await run_import_candidate(
+ "obsolete_hash_import",
+ str(path),
+ candidate_ids=None, # None uses best match
+ duplicate_actions=None, # None uses config
+ )
+ assert exc is None, "Should not return an error"
+
+ # Check if mapping is still correctly after import
+ assert self.check_mapping_consistency(db_session)
+
+ # Check that status was emitted correctly, we emit once before and once after run
+ assert len(self.statuses) == 2
+ assert self.statuses[0].status == FolderStatus.IMPORTING
+ assert self.statuses[1].status == FolderStatus.IMPORTED
+
+ # Check db still contains one tagged folder
+ stmt = select(SessionStateInDb)
+ s_state_indb = db_session.execute(stmt).scalar()
+
+ assert s_state_indb is not None
+ assert s_state_indb.folder.full_path == str(path)
+
+ # Check preview content is correct
+ s_state_live = s_state_indb.to_live_state()
+ assert s_state_live is not None
+ assert s_state_live.folder_path == path
+ assert len(s_state_live.task_states) == 1
+ assert s_state_live.tasks[0].old_paths is not None
+
+ t_state_live = s_state_live.task_states[0]
+ assert t_state_live.progress == Progress.IMPORT_COMPLETED
+
+ for c in t_state_live.candidate_states:
+ assert len(c.duplicate_ids) == 0, (
+ "Should not have duplicates in empty library"
+ )
+ assert t_state_live.chosen_candidate_state_id is not None
+
+ # Check that we have the items in the beets lib
+ albums = self.beets_lib.albums()
+ assert len(albums) == 1, "Should have imported one album"
+ items = albums[0].items()
+ assert len(items) == 1, "Should have imported one item"
+
+ # gui import ids are set
+ album = albums[0]
+ assert hasattr(album, "gui_import_id"), "Album should have gui_import_id"
+ assert album.gui_import_id is not None, "Album should have gui_import_id"
+
+ async def test_reimport_fails(self, db_session: Session, path: Path):
+ """Reimport should fail if the state is already imported.
+
+ We use errors as values here so we need to check the return value
+ """
+ stmt = select(func.count()).select_from(SessionStateInDb)
+ assert db_session.execute(stmt).scalar() == 1, (
+ "Database should contain the one preview session state from the previous test"
+ )
+
+ self.statuses = []
+
+ exc = await run_import_candidate(
+ "obsolete_hash_import",
+ str(path),
+ candidate_ids=None, # None uses best match
+ duplicate_actions={"*": "ask"},
+ )
+
+ assert exc is not None
+ assert exc["message"] == "Cannot redo imports. Try undo and/or retag!"
+
+ assert len(self.statuses) == 2
+ assert self.statuses[0].status == FolderStatus.IMPORTING
+ assert self.statuses[1].status == FolderStatus.FAILED
+
+ async def test_duplicate_import_fails(self, path: Path):
+ """
+ Duplicates should normally only happen if you import the same
+ items from a different folder.
+
+ We use the same items but a different folder here ;) A bit
+ hacky but works for our purpose.
+ """
+
+ # Check item already in beets library
+ albums = self.beets_lib.albums()
+ assert len(albums) == 1, "Should have imported one album"
+
+ await run_preview(
+ "obsolete_hash_preview",
+ str(path / "Chant [SINGLE]"),
+ group_albums=None,
+ autotag=None,
+ )
+
+ exc = await run_import_candidate(
+ "obsolete_hash_import",
+ str(path / "Chant [SINGLE]"),
+ candidate_ids=None, # None uses best match
+ duplicate_actions={"*": "ask"}, # ask raises on duplicate
+ )
+
+ # FIXME: We might want to raise our own exception here
+ assert exc is not None
+ assert exc["type"] == "DuplicateException"
+
+ async def test_undo(self, db_session: Session, path: Path):
+ """Test the undo of the import process.
+
+ This should remove the items from the beets library and
+ set the progress back to PREVIEW_COMPLETED. Also the disk
+ items should be removed/moved back.
+ """
+
+ f = Folder.from_path(path)
+
+ items = self.beets_lib.items()
+ item = items[0]
+ assert item is not None, "Should have imported at least one item for this test."
+ imported_path = Path(item.path.decode("utf-8"))
+
+ self.statuses = []
+ exc = await run_import_undo(
+ f.hash,
+ str(path),
+ delete_files=True,
+ )
+
+ assert exc is None
+ assert len(self.statuses) == 2
+ assert self.statuses[0].status == FolderStatus.DELETING
+ assert self.statuses[1].status == FolderStatus.DELETED
+
+ items = self.beets_lib.items()
+ assert len(items) == 0, "Should have removed all items from beets library"
+ assert not imported_path.exists(), "Should have removed the imported files"
+
+ async def test_undo_fails(self, db_session: Session, path: Path):
+ """If the session is not in a imported state we should fail."""
+ f = Folder.from_path(path)
+
+ exc = await run_import_undo(
+ f.hash,
+ str(path),
+ delete_files=True,
+ )
+
+ assert exc is not None
+ assert "Cannot undo if never imported" in exc["message"]
+
+ async def test_reimport_after_undo(self, db_session: Session, path: Path):
+ # Case two: Import session valid but no beets items
+ exc = await run_import_candidate(
+ "obsolete_hash_import",
+ str(path),
+ candidate_ids=None, # None uses best match
+ duplicate_actions=None, # None uses config
+ )
+ assert exc is None
+
+ # Check that we have the items in the beets lib
+ albums = self.beets_lib.albums()
+ assert len(albums) == 1, "Should have imported one album"
+ items = albums[0].items()
+ assert len(items) == 1, "Should have imported one item"
+
+ # Check files have been imported
+ imported_path = Path(items[0].path.decode("utf-8"))
+ assert imported_path.exists(), "Should have imported the files"
+ assert imported_path.is_file(), "Should have imported the files"
+
+ @pytest.mark.parametrize("duplicate_action", ["skip", "merge", "remove", "keep"])
+ async def test_duplicate_with_action(
+ self, db_session: Session, path: Path, duplicate_action
+ ):
+ """Test the duplicate action with a different action.
+
+ This should not return an error but just do the action (if not ask).
+ """
+
+ # Check item already in beets library
+ p = str(path / "Chant [SINGLE]")
+ albums = self.beets_lib.albums()
+ assert len(albums) == 1, "Should have imported one album"
+
+ # Reset session state to PREVIEW_COMPLETED to allow to reuse it on
+ # multiple runs of this test
+ stmt = (
+ select(SessionStateInDb).join(FolderInDb).where(FolderInDb.full_path == p)
+ )
+ session_state = db_session.execute(stmt).scalar()
+ assert session_state is not None
+
+ # Reset progress to PREVIEW_COMPLETED
+ for task in session_state.tasks:
+ task.progress = Progress.PREVIEW_COMPLETED
+ db_session.commit()
+
+ self.statuses = []
+ exc = await run_import_candidate(
+ "obsolete_hash_import",
+ p,
+ candidate_ids=None, # None uses best match
+ duplicate_actions={"*": duplicate_action},
+ )
+
+ # Shouldn't return an error
+ assert exc is None
+ assert len(self.statuses) == 2
+ assert self.statuses[0].status == FolderStatus.IMPORTING
+ assert self.statuses[1].status == FolderStatus.IMPORTED
+
+ # After import we should not have a duplicate id anymore
+ session_state = db_session.execute(stmt).scalar()
+ assert session_state is not None
+ live_state = session_state.to_live_state()
+ assert live_state is not None
+
+ for task in live_state.task_states:
+ chosen_candidate = task.chosen_candidate_state
+ assert chosen_candidate is not None
+ assert len(chosen_candidate.duplicate_ids) == 0, (
+ "Should not have duplicates after import"
+ )
+
+ async def test_undo_with_missing_beets_items(self, db_session: Session, path: Path):
+ f = Folder.from_path(path)
+ items = self.beets_lib.items()
+
+ with self.beets_lib.transaction() as tx:
+ for item in items:
+ item.remove()
+
+ exc = await run_import_undo(
+ f.hash,
+ str(path),
+ delete_files=True,
+ )
+
+ assert exc is not None
+ assert exc["type"] == "IntegrityException"
+
+
+class TestImportAuto(SendStatusMockMixin, IsolatedDBMixin, IsolatedBeetsLibraryMixin):
+ """Test that the preview + threshold-dependent import works.
+
+ The flow is as follows:
+ - Generate Preview
+ - Check treshold
+ - Import best candidate, but only when better than specified distance
+ """
+
+ @pytest.fixture()
+ def path(self) -> Path:
+ path = album_path_absolute(VALID_PATHS[0])
+ use_mock_tag_album(str(path))
+ return path
+
+ async def test_import_auto_accept(self, db_session: Session, path: Path):
+ """
+ Check that the import either fails or goes through, depending on the threshold.
+ """
+ stmt = select(SessionStateInDb).order_by(SessionStateInDb.created_at.desc())
+ assert db_session.execute(stmt).scalar() is None, (
+ "Database should be empty before the test"
+ )
+
+ self.statuses = []
+
+ await run_preview(
+ "obsolete_hash_preview",
+ str(path),
+ group_albums=None,
+ autotag=None,
+ )
+
+ await run_import_auto(
+ "obsolete_hash_import_auto",
+ str(path),
+ import_threshold=0.0,
+ duplicate_actions={"*": "remove"},
+ )
+
+ assert len(self.statuses) == 4
+ assert self.statuses[2].status == FolderStatus.IMPORTING
+ assert self.statuses[3].status == FolderStatus.FAILED
+ assert len(self.beets_lib.albums()) == 0
+
+ await run_import_auto(
+ "obsolete_hash_import_auto",
+ str(path),
+ import_threshold=1.0,
+ duplicate_actions={"*": "remove"},
+ )
+
+ assert len(self.statuses) == 6
+ assert self.statuses[4].status == FolderStatus.IMPORTING
+ assert self.statuses[5].status == FolderStatus.IMPORTED
+ assert len(self.beets_lib.albums()) == 1
+
+
+class TestImportAutoFails(
+ SendStatusMockMixin, IsolatedDBMixin, IsolatedBeetsLibraryMixin
+):
+ @pytest.fixture()
+ def path(self) -> Path:
+ path = album_path_absolute(VALID_PATHS[0])
+ use_mock_tag_album(str(path))
+ return path
+
+ async def test_import_auto_fails(self, db_session: Session, path: Path):
+ stmt = select(SessionStateInDb).order_by(SessionStateInDb.created_at.desc())
+ assert db_session.execute(stmt).scalar() is None, (
+ "Database should be empty before the test"
+ )
+
+ self.statuses = []
+
+ await run_preview(
+ "obsolete_hash_preview",
+ str(path),
+ group_albums=None,
+ autotag=None,
+ )
+
+ exc = await run_import_auto(
+ "obsolete_hash_import_auto",
+ str(path),
+ import_threshold=-1.0,
+ duplicate_actions={"*": "remove"},
+ )
+ assert exc is not None, f"Should return an error {exc}"
+
+ assert len(self.statuses) == 4
+ assert self.statuses[2].status == FolderStatus.IMPORTING
+ assert self.statuses[3].status == FolderStatus.FAILED
+ assert len(self.beets_lib.albums()) == 0 # one from the previous test
+
+ stmt = select(SessionStateInDb).order_by(SessionStateInDb.created_at.desc())
+ s_state_indb = db_session.execute(stmt).scalar()
+ assert s_state_indb is not None
+ assert s_state_indb.exception is not None
+
+ # After a failed import, we should be able to import again manually
+ exc = await run_import_candidate(
+ "obsolete_hash_import",
+ str(path),
+ candidate_ids=None, # None uses best match
+ duplicate_actions={"*": "remove"},
+ )
+ assert exc is None, "Should not return an error"
+
+ # The database session state should not contain an exception anymore
+ stmt = select(SessionStateInDb).order_by(SessionStateInDb.created_at.desc())
+ s_state_indb = db_session.execute(stmt).scalar()
+ assert s_state_indb is not None
+ assert s_state_indb.exception is None, "Exception should have been cleared"
+
+
+class TestChooseCandidatesSingleTask(
+ SendStatusMockMixin, IsolatedDBMixin, IsolatedBeetsLibraryMixin
+):
+ """Test a typical import using a choosen candidate."""
+
+ @pytest.fixture()
+ def path_single_task(self) -> Path:
+ path = album_path_absolute(VALID_PATHS[0])
+ use_mock_tag_album(str(path))
+ return path
+
+ async def test_choose_candidates(
+ self,
+ db_session: Session,
+ path_single_task: Path,
+ ):
+ """Test the import of the tagged folder using a candidate id (single task in session)"""
+
+ exc = await run_preview(
+ "obsolete_hash_preview",
+ str(path_single_task),
+ group_albums=None,
+ autotag=None,
+ )
+ assert exc is None, "Should not return an error"
+
+ # Check db contains the tagged folder
+ stmt = select(SessionStateInDb)
+ s_state_indb = db_session.execute(stmt).scalar()
+
+ assert s_state_indb is not None
+ assert s_state_indb.folder.full_path == str(path_single_task)
+ assert len(s_state_indb.tasks) == 1
+
+ choosen_candidate = s_state_indb.tasks[0].candidates[-2]
+
+ exc = await run_import_candidate(
+ "obsolete_hash_import",
+ str(path_single_task),
+ candidate_ids={"*": choosen_candidate.id},
+ duplicate_actions=None, # None uses config
+ )
+ assert exc is None, "Should not return an error"
+
+ # Check db still contains one tagged folder
+ stmt = select(SessionStateInDb)
+ s_state_indb = db_session.execute(stmt).scalar()
+ assert s_state_indb is not None
+ assert s_state_indb.folder.full_path == str(path_single_task)
+
+ # Check choosen candidate is the one we imported
+ s_state_live = s_state_indb.to_live_state()
+ assert s_state_live is not None
+ assert s_state_live.folder_path == path_single_task
+ assert len(s_state_live.task_states) == 1
+ assert s_state_live.tasks[0].old_paths is not None
+ t_state_live = s_state_live.task_states[0]
+ assert t_state_live.progress == Progress.IMPORT_COMPLETED
+ assert t_state_live.chosen_candidate_state is not None
+ assert t_state_live.chosen_candidate_state_id == choosen_candidate.id
+
+
+class TestMultipleTasks(
+ SendStatusMockMixin, IsolatedDBMixin, IsolatedBeetsLibraryMixin
+):
+ """Test a typical import of a multiple tasks using choosen candidates."""
+
+ @pytest.fixture()
+ def path_multiple_tasks(self) -> Path:
+ path = album_path_absolute("multi")
+ use_mock_tag_album(str(path))
+ return path
+
+ async def test_choose_candidates_multiple_tasks(
+ self,
+ db_session: Session,
+ path_multiple_tasks: Path,
+ ):
+ """Test the import of the tagged folder."""
+
+ exc = await run_preview(
+ "obsolete_hash_preview",
+ str(path_multiple_tasks),
+ group_albums=None,
+ autotag=None,
+ )
+ assert exc is None, "Should not return an error"
+
+ # Check db contains the tagged folder with multiple tasks
+ stmt = select(SessionStateInDb)
+ s_state_indb: SessionStateInDb | None = db_session.execute(stmt).scalar()
+ assert s_state_indb is not None
+ assert len(s_state_indb.tasks) > 1, "Should have multiple tasks"
+
+ # For each task, choose a different candidate
+
+ candidates: TaskIdMappingArg[CandidateChoice] = {}
+ assert candidates is not None
+ for task in s_state_indb.tasks:
+ print(task.paths)
+ print([c.metadata for c in task.candidates])
+ assert len(task.candidates) > 2, "Should have candidates"
+ candidates[task.id] = task.candidates[-2].id
+
+ # Check that we have the same number of candidates as tasks
+ assert len(candidates) == len(s_state_indb.tasks), (
+ "Should have same number of candidates as tasks"
+ )
+
+ exc = await run_import_candidate(
+ "obsolete_hash_import",
+ str(path_multiple_tasks),
+ candidate_ids=candidates,
+ duplicate_actions=None, # None uses config
+ )
+ assert exc is None, "Should not return an error"
+
+ # Check db still contains one tagged folder
+ stmt = select(SessionStateInDb)
+ s_state_indb = db_session.execute(stmt).scalar()
+ assert s_state_indb is not None
+ assert s_state_indb.folder.full_path == str(path_multiple_tasks)
+ assert len(s_state_indb.tasks) > 1, "Should have multiple tasks"
+
+ @pytest.mark.parametrize("duplicate_action", ["skip", "merge", "remove", "keep"])
+ async def test_duplicate_action(
+ self,
+ db_session: Session,
+ path_multiple_tasks: Path,
+ duplicate_action: Literal["skip", "merge", "remove", "keep"],
+ ):
+ """Test the import of the tagged folder with duplicate action."""
+
+ # Check db contains the tagged folder with multiple tasks
+ stmt = select(SessionStateInDb)
+ s_state_indb = db_session.execute(stmt).scalar()
+ assert s_state_indb is not None
+ assert len(s_state_indb.tasks) > 1, "Should have multiple tasks"
+
+ # Reset session state to PREVIEW_COMPLETED to allow to reuse it on
+ # multiple runs of this test
+ stmt = (
+ select(SessionStateInDb)
+ .join(FolderInDb)
+ .where(FolderInDb.full_path == str(path_multiple_tasks))
+ )
+ session_state = db_session.execute(stmt).scalar()
+ assert session_state is not None
+ # Reset progress to PREVIEW_COMPLETED
+ for task in session_state.tasks:
+ task.progress = Progress.PREVIEW_COMPLETED
+ db_session.commit()
+
+ # For each task, choose a different candidate and duplicate action
+ duplicate_actions: TaskIdMappingArg[DuplicateAction] = {}
+ candidates: TaskIdMappingArg[CandidateChoice] = {}
+ assert candidates is not None
+ assert duplicate_actions is not None
+
+ for task in s_state_indb.tasks:
+ assert len(task.candidates) > 2, "Should have candidates"
+ candidates[task.id] = task.candidates[-2].id
+ duplicate_actions[task.id] = duplicate_action
+
+ # Check that we have the same number of candidates as tasks
+ assert len(candidates) == len(s_state_indb.tasks), (
+ "Should have same number of candidates as tasks"
+ )
+
+ exc = await run_import_candidate(
+ "obsolete_hash_import",
+ str(path_multiple_tasks),
+ candidate_ids=candidates,
+ duplicate_actions=duplicate_actions,
+ )
+ assert exc is None, "Should not return an error"
+
+
+class TestPluginEvents(
+ SendStatusMockMixin, IsolatedDBMixin, IsolatedBeetsLibraryMixin, PluginEventsMixin
+):
+ """Test that the plugin events are triggered correctly.
+
+ This is important to maintain compatibility with beets plugins that
+ expect certain events to be triggered during the import/tag process.
+ """
+
+ @pytest.fixture()
+ def path(self) -> Path:
+ path = album_path_absolute(VALID_PATHS[0])
+ use_mock_tag_album(str(path))
+ return path
+
+ async def test_preview_events(self, db_session: Session, path: Path):
+ self.events = []
+
+ await run_preview(
+ "obsolete_hash_preview",
+ str(path),
+ group_albums=None,
+ autotag=None,
+ )
+
+ assert "import_begin" in self.events[0]
+ assert "import_task_created" in self.events[1]
+ assert "import_task_start" in self.events[2]
+ assert len(self.events) == 3
+
+ async def test_import_auto_events(self, db_session: Session, path: Path):
+ self.events = []
+
+ exc = await run_import_auto(
+ "obsolete_hash_import_auto",
+ str(path),
+ import_threshold=1.0,
+ duplicate_actions={"*": "remove"},
+ )
+ assert exc is None, "Should not return an error"
+
+ assert "import_begin" == self.events[0]
+ # TODO: Does not trigger import_task_created and import_task_start
+ assert "import_task_before_choice" in self.events[1]
+ assert "import_task_choice" in self.events[2]
+ assert "import_task_apply" in self.events[3]
+ # TODO: Seems like quite some database change operations are done here
+ assert "cli_exit" in self.events[-1]
+
+ async def test_undo_events(self, db_session: Session, path: Path):
+ self.events = []
+
+ exc = await run_import_undo(
+ "obsolete_hash_import",
+ str(path),
+ delete_files=True,
+ )
+ assert exc is None, "Should not return an error"
+
+ # TODO: Does not trigger started events
+ assert "item_removed" in self.events
+ assert "album_removed" in self.events
+ assert "cli_exit" == self.events[-1]
+
+
+class TestImportAsis(SendStatusMockMixin, IsolatedDBMixin, IsolatedBeetsLibraryMixin):
+ """Test a typical import using the asis candidate.
+
+ We have an extra test for this as the asis candidate is a bit special,
+ it is generated by us and uses the original file metadata. Thus just
+ to be sure we test it separately.
+
+ The flow is as follows:
+ - Generate Preview
+ - Import asis candidate
+ - Trying to reimport same session should fail
+ - Trying to import another session with duplicate candidate should fail
+ """
+
+ @pytest.mark.skip(reason="Implement")
+ def test_import_asis(self, db_session: Session, path: Path):
+ raise NotImplementedError("Implement me")
+
+
+class TestImportCandidate(
+ SendStatusMockMixin, IsolatedDBMixin, IsolatedBeetsLibraryMixin
+):
+ """Test a typical import using the specific candidate.
+
+ The flow is as follows:
+ - Generate Preview
+ - Import specific candidate
+ - Trying to reimport same session should fail
+ - Trying to import another session with duplicate candidate should fail
+ """
+
+ @pytest.mark.skip(reason="Implement")
+ def test_import_candidate(self, db_session: Session, path: Path):
+ raise NotImplementedError("Implement me")
+
+
+class TestImportBootleg(
+ SendStatusMockMixin, IsolatedDBMixin, IsolatedBeetsLibraryMixin
+):
+ """Test that import without lookup works.
+
+ The flow is as follows:
+ - Import candidate asis
+ """
+
+ @pytest.fixture()
+ def path(self) -> Path:
+ path = album_path_absolute(VALID_PATHS[0])
+ use_mock_tag_album(str(path))
+ return path
+
+ async def test_import_bootleg(self, db_session: Session, path: Path):
+ """
+ Check that the import goes through, no matter what.
+ """
+ self.statuses = []
+ self.reset_database()
+
+ stmt = select(SessionStateInDb).order_by(SessionStateInDb.created_at.desc())
+ assert db_session.execute(stmt).scalar() is None, (
+ "Database should be empty before the test"
+ )
+
+ self.statuses = []
+
+ exc = await run_import_bootleg(
+ "obsolete_hash_import_auto",
+ str(path),
+ )
+
+ assert exc is None, "Should not return an error"
+
+ assert len(self.statuses) == 2
+ assert self.statuses[0].status == FolderStatus.IMPORTING
+ assert self.statuses[1].status == FolderStatus.IMPORTED
+ assert len(self.beets_lib.albums()) == 1
diff --git a/backend/tests/integration/test_routes/test_config.py b/backend/tests/integration/test_routes/test_config.py
index 3d6c1975..0a8dbdc3 100644
--- a/backend/tests/integration/test_routes/test_config.py
+++ b/backend/tests/integration/test_routes/test_config.py
@@ -1,22 +1,22 @@
-import pytest
-
-
-@pytest.mark.asyncio
-async def test_get_all(client):
- # Not really much we can test here
- response = await client.get("/api_v1/config/all")
-
- assert response.status_code == 200
-
-
-@pytest.mark.asyncio
-async def test_get_basic(client):
- response = await client.get("/api_v1/config/")
-
- assert response.status_code == 200
-
- data = await response.get_json()
-
- assert "gui" in data
- assert "import" in data
- assert "match" in data
+import pytest
+
+
+@pytest.mark.asyncio
+async def test_get_all(client):
+ # Not really much we can test here
+ response = await client.get("/api_v1/config/all")
+
+ assert response.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_get_basic(client):
+ response = await client.get("/api_v1/config/")
+
+ assert response.status_code == 200
+
+ data = await response.get_json()
+
+ assert "gui" in data
+ assert "import" in data
+ assert "match" in data
diff --git a/backend/tests/integration/test_routes/test_db_models.py b/backend/tests/integration/test_routes/test_db_models.py
index 636151d6..0692c93b 100644
--- a/backend/tests/integration/test_routes/test_db_models.py
+++ b/backend/tests/integration/test_routes/test_db_models.py
@@ -1,137 +1,137 @@
-from pathlib import Path
-
-import pytest
-from beets import autotag, importer
-from quart.typing import TestClientProtocol as Client
-from sqlalchemy import select
-from sqlalchemy.orm import Session
-
-from beets_flask.database.models.states import FolderInDb, SessionStateInDb
-from beets_flask.importer.states import SessionState
-from tests.conftest import beets_lib_item
-from tests.unit.test_importer.test_states import get_album_match
-
-
-@pytest.fixture
-def import_task(beets_lib):
- item = beets_lib_item(title="title", path="path")
- task = importer.ImportTask(paths=[b"a path"], toppath=b"top path", items=[item])
-
- track_info = autotag.TrackInfo(title="match title")
- album_match = get_album_match(
- [track_info], [item], album="match album", data_url="url"
- )
-
- task.candidates = [album_match]
- return task
-
-
-@pytest.fixture
-async def session_in_db(db_session_factory, import_task, tmpdir_factory):
- # Add a session to the database
- sessions: list[SessionState] = []
- for i in range(3):
- session = SessionState(Path(tmpdir_factory.mktemp(f"session_{i}")))
- with db_session_factory() as db_session:
- session.upsert_task(import_task)
- session_in_db = SessionStateInDb.from_live_state(session)
- db_session.add(session_in_db)
- db_session.commit()
- sessions.append(session)
-
- yield sessions
-
- # Clean up the database
- with db_session_factory() as db_session:
- db_session.query(SessionStateInDb).delete()
- db_session.query(FolderInDb).delete()
- db_session.commit()
-
-
-class TestSessionEndpoint:
- """Test the end to end functionality of the model endpoints.
-
- We automatically generate the endpoints for the sqlalchemy models. Thus we also
- test each generated endpoint here in a relatively generic way.
- """
-
- @pytest.mark.asyncio
- async def test_get_all_empty(self, client: Client, db_session: Session):
- # If no database objects are present, the response should be an empty list.
- # And no pagination.
-
- # Clear library
- sessions = db_session.execute(select(SessionStateInDb)).scalars().all()
- for s in sessions:
- db_session.delete(s)
- db_session.commit()
-
- response = await client.get("/api_v1/session")
- assert response.status_code == 200
- data = await response.get_json()
- assert isinstance(data, dict)
-
- assert data["items"] == []
- assert data["next"] is None
-
- @pytest.mark.asyncio
- async def test_get_all(self, client: Client, session_in_db):
- response = await client.get("/api_v1/session")
- assert response.status_code == 200
- data = await response.get_json()
- assert isinstance(data, dict)
-
- assert len(data["items"]) == len(session_in_db)
- assert data["next"] is None
-
- @pytest.mark.asyncio
- async def test_get_all_pageination(self, client, session_in_db):
- response = await client.get("/api_v1/session?n_items=1")
- assert response.status_code == 200
- data = await response.get_json()
- assert isinstance(data, dict)
-
- assert len(data["items"]) == 1
- assert data["next"] is not None
-
- # Try to iter all pages
- items = data["items"]
- next = data["next"]
- while next:
- response = await client.get(next)
- assert response.status_code == 200
- data = await response.get_json()
- assert isinstance(data, dict)
- next = data["next"]
- items.extend(data["items"])
-
- assert len(items) == len(session_in_db)
-
- @pytest.mark.asyncio
- async def test_invalid_id(self, client: Client):
- response = await client.get("/api_v1/session/id/invalid_id")
- assert response.status_code == 404
-
- @pytest.mark.asyncio
- async def test_invalid_param(self, client: Client):
- response = await client.get("/api_v1/session?cursor=invalid")
- assert response.status_code == 400
-
- @pytest.mark.asyncio
- async def test_get_by_id(self, client: Client, session_in_db):
- for s in session_in_db:
- response = await client.get(f"/api_v1/session/id/{s.id}")
- assert response.status_code == 200
- data = await response.get_json()
- assert isinstance(data, dict)
- assert data["id"] == s.id
-
-
-@pytest.mark.skip("Not implemented")
-class TestTaskEndpoint:
- pass
-
-
-@pytest.mark.skip("Not implemented")
-class TestCandidateEndpoint:
- pass
+from pathlib import Path
+
+import pytest
+from beets import autotag, importer
+from quart.typing import TestClientProtocol as Client
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from beets_flask.database.models.states import FolderInDb, SessionStateInDb
+from beets_flask.importer.states import SessionState
+from tests.conftest import beets_lib_item
+from tests.unit.test_importer.test_states import get_album_match
+
+
+@pytest.fixture
+def import_task(beets_lib):
+ item = beets_lib_item(title="title", path="path")
+ task = importer.ImportTask(paths=[b"a path"], toppath=b"top path", items=[item])
+
+ track_info = autotag.TrackInfo(title="match title")
+ album_match = get_album_match(
+ [track_info], [item], album="match album", data_url="url"
+ )
+
+ task.candidates = [album_match]
+ return task
+
+
+@pytest.fixture
+async def session_in_db(db_session_factory, import_task, tmpdir_factory):
+ # Add a session to the database
+ sessions: list[SessionState] = []
+ for i in range(3):
+ session = SessionState(Path(tmpdir_factory.mktemp(f"session_{i}")))
+ with db_session_factory() as db_session:
+ session.upsert_task(import_task)
+ session_in_db = SessionStateInDb.from_live_state(session)
+ db_session.add(session_in_db)
+ db_session.commit()
+ sessions.append(session)
+
+ yield sessions
+
+ # Clean up the database
+ with db_session_factory() as db_session:
+ db_session.query(SessionStateInDb).delete()
+ db_session.query(FolderInDb).delete()
+ db_session.commit()
+
+
+class TestSessionEndpoint:
+ """Test the end to end functionality of the model endpoints.
+
+ We automatically generate the endpoints for the sqlalchemy models. Thus we also
+ test each generated endpoint here in a relatively generic way.
+ """
+
+ @pytest.mark.asyncio
+ async def test_get_all_empty(self, client: Client, db_session: Session):
+ # If no database objects are present, the response should be an empty list.
+ # And no pagination.
+
+ # Clear library
+ sessions = db_session.execute(select(SessionStateInDb)).scalars().all()
+ for s in sessions:
+ db_session.delete(s)
+ db_session.commit()
+
+ response = await client.get("/api_v1/session")
+ assert response.status_code == 200
+ data = await response.get_json()
+ assert isinstance(data, dict)
+
+ assert data["items"] == []
+ assert data["next"] is None
+
+ @pytest.mark.asyncio
+ async def test_get_all(self, client: Client, session_in_db):
+ response = await client.get("/api_v1/session")
+ assert response.status_code == 200
+ data = await response.get_json()
+ assert isinstance(data, dict)
+
+ assert len(data["items"]) == len(session_in_db)
+ assert data["next"] is None
+
+ @pytest.mark.asyncio
+ async def test_get_all_pageination(self, client, session_in_db):
+ response = await client.get("/api_v1/session?n_items=1")
+ assert response.status_code == 200
+ data = await response.get_json()
+ assert isinstance(data, dict)
+
+ assert len(data["items"]) == 1
+ assert data["next"] is not None
+
+ # Try to iter all pages
+ items = data["items"]
+ next = data["next"]
+ while next:
+ response = await client.get(next)
+ assert response.status_code == 200
+ data = await response.get_json()
+ assert isinstance(data, dict)
+ next = data["next"]
+ items.extend(data["items"])
+
+ assert len(items) == len(session_in_db)
+
+ @pytest.mark.asyncio
+ async def test_invalid_id(self, client: Client):
+ response = await client.get("/api_v1/session/id/invalid_id")
+ assert response.status_code == 404
+
+ @pytest.mark.asyncio
+ async def test_invalid_param(self, client: Client):
+ response = await client.get("/api_v1/session?cursor=invalid")
+ assert response.status_code == 400
+
+ @pytest.mark.asyncio
+ async def test_get_by_id(self, client: Client, session_in_db):
+ for s in session_in_db:
+ response = await client.get(f"/api_v1/session/id/{s.id}")
+ assert response.status_code == 200
+ data = await response.get_json()
+ assert isinstance(data, dict)
+ assert data["id"] == s.id
+
+
+@pytest.mark.skip("Not implemented")
+class TestTaskEndpoint:
+ pass
+
+
+@pytest.mark.skip("Not implemented")
+class TestCandidateEndpoint:
+ pass
diff --git a/backend/tests/integration/test_routes/test_exceptions.py b/backend/tests/integration/test_routes/test_exceptions.py
index a5c6fd1d..ccd29e68 100644
--- a/backend/tests/integration/test_routes/test_exceptions.py
+++ b/backend/tests/integration/test_routes/test_exceptions.py
@@ -1,83 +1,83 @@
-class TestErrorHandling:
- """Tests our exception handling capabilities."""
-
- async def test_api_error(self, client):
- response = await client.get("/api_v1/error/api")
- data = await response.get_json()
- assert response.status_code == 500
-
- # Returned data is a SerializedException
- assert data["type"] == "ApiException"
- assert data["message"] == "This is a bad request"
- assert data["description"] is not None
-
- async def test_invalid_usage(self, client):
- response = await client.get("/api_v1/error/invalidUsage")
- data = await response.get_json()
- assert response.status_code == 400
-
- # Returned data is a SerializedException
- assert data["type"] == "InvalidUsageException"
- assert data["message"] == "This is a bad request"
- assert data["description"] is not None
-
- async def test_not_implemented(self, client):
- response = await client.get("/api_v1/error/notImplemented")
- data = await response.get_json()
- assert response.status_code == 501
-
- # Returned data is a SerializedException
- assert data["type"] == "NotImplementedError"
- assert data["message"] == "This is not implemented"
- assert data["description"] is not None
-
- async def test_integrity_error(self, client):
- response = await client.get("/api_v1/error/integrity")
- data = await response.get_json()
- assert response.status_code == 409
-
- # Returned data is a SerializedException
- assert data["type"] == "IntegrityException"
- assert data["message"] == "This is an integrity error"
- assert data["description"] is not None
-
- async def test_config_error(self, client):
- response = await client.get("/api_v1/error/configError")
- data = await response.get_json()
- assert response.status_code == 400
-
- # Returned data is a SerializedException
- assert data["type"] == "ConfigError"
- assert data["message"] == "This is a config error"
- assert data["description"] is not None
-
- async def test_file_not_found(self, client):
- response = await client.get("/api_v1/error/fileNotFound")
- data = await response.get_json()
- assert response.status_code == 404
-
- # Returned data is a SerializedException
- assert data["type"] == "FileNotFoundError"
- assert data["message"] == "This is a file not found error"
- assert data["description"] is not None
-
- async def test_generic_error(self, client):
- response = await client.get("/api_v1/error/genericError")
- data = await response.get_json()
- assert response.status_code == 500
-
- # Returned data is a SerializedException
- assert data["type"] == "Exception"
- assert data["message"] == "An unhandled exception occurred"
- assert data["description"] is not None
- assert data["trace"] is not None
-
- async def test_not_found(self, client):
- response = await client.get("/api_v1/error/notFound")
- data = await response.get_json()
- assert response.status_code == 404
-
- # Returned data is a SerializedException
- assert data["type"] == "NotFoundException"
- assert data["message"] == "This is a not found error"
- assert data["description"] is not None
+class TestErrorHandling:
+ """Tests our exception handling capabilities."""
+
+ async def test_api_error(self, client):
+ response = await client.get("/api_v1/error/api")
+ data = await response.get_json()
+ assert response.status_code == 500
+
+ # Returned data is a SerializedException
+ assert data["type"] == "ApiException"
+ assert data["message"] == "This is a bad request"
+ assert data["description"] is not None
+
+ async def test_invalid_usage(self, client):
+ response = await client.get("/api_v1/error/invalidUsage")
+ data = await response.get_json()
+ assert response.status_code == 400
+
+ # Returned data is a SerializedException
+ assert data["type"] == "InvalidUsageException"
+ assert data["message"] == "This is a bad request"
+ assert data["description"] is not None
+
+ async def test_not_implemented(self, client):
+ response = await client.get("/api_v1/error/notImplemented")
+ data = await response.get_json()
+ assert response.status_code == 501
+
+ # Returned data is a SerializedException
+ assert data["type"] == "NotImplementedError"
+ assert data["message"] == "This is not implemented"
+ assert data["description"] is not None
+
+ async def test_integrity_error(self, client):
+ response = await client.get("/api_v1/error/integrity")
+ data = await response.get_json()
+ assert response.status_code == 409
+
+ # Returned data is a SerializedException
+ assert data["type"] == "IntegrityException"
+ assert data["message"] == "This is an integrity error"
+ assert data["description"] is not None
+
+ async def test_config_error(self, client):
+ response = await client.get("/api_v1/error/configError")
+ data = await response.get_json()
+ assert response.status_code == 400
+
+ # Returned data is a SerializedException
+ assert data["type"] == "ConfigError"
+ assert data["message"] == "This is a config error"
+ assert data["description"] is not None
+
+ async def test_file_not_found(self, client):
+ response = await client.get("/api_v1/error/fileNotFound")
+ data = await response.get_json()
+ assert response.status_code == 404
+
+ # Returned data is a SerializedException
+ assert data["type"] == "FileNotFoundError"
+ assert data["message"] == "This is a file not found error"
+ assert data["description"] is not None
+
+ async def test_generic_error(self, client):
+ response = await client.get("/api_v1/error/genericError")
+ data = await response.get_json()
+ assert response.status_code == 500
+
+ # Returned data is a SerializedException
+ assert data["type"] == "Exception"
+ assert data["message"] == "An unhandled exception occurred"
+ assert data["description"] is not None
+ assert data["trace"] is not None
+
+ async def test_not_found(self, client):
+ response = await client.get("/api_v1/error/notFound")
+ data = await response.get_json()
+ assert response.status_code == 404
+
+ # Returned data is a SerializedException
+ assert data["type"] == "NotFoundException"
+ assert data["message"] == "This is a not found error"
+ assert data["description"] is not None
diff --git a/backend/tests/integration/test_routes/test_inbox.py b/backend/tests/integration/test_routes/test_inbox.py
index aa629edd..8dd1ff83 100644
--- a/backend/tests/integration/test_routes/test_inbox.py
+++ b/backend/tests/integration/test_routes/test_inbox.py
@@ -1,119 +1,119 @@
-import os
-import shutil
-from pathlib import Path
-
-import pytest
-
-from beets_flask.disk import Folder
-
-from ..test_flows import SendStatusMockMixin
-
-
-class TestDeleteEndpoint(SendStatusMockMixin):
- tmp_path: Path
-
- @pytest.fixture(autouse=True)
- def create_folder(self, tmp_path):
- # Create some folders
- os.makedirs(tmp_path / "basic")
- os.makedirs(tmp_path / "complex")
- os.makedirs(tmp_path / "complex" / "nested")
- with open(tmp_path / "basic" / "file1.txt", "w") as f:
- f.write("Hello World")
- with open(tmp_path / "complex" / "file2.txt", "w") as f:
- f.write("Hello World 12")
- with open(tmp_path / "complex" / "nested" / "file3.txt", "w") as f:
- f.write("Hello World 123")
-
- self.tmp_path = tmp_path
-
- yield
-
- # Cleanup after the test
- shutil.rmtree(tmp_path / "basic", ignore_errors=True)
- shutil.rmtree(tmp_path / "test2", ignore_errors=True)
-
- async def test_basic_delete(self, client):
- # Assuming the hashes are already known
- f1 = Folder.from_path(self.tmp_path / "basic")
-
- response = await client.delete(
- "/api_v1/inbox/delete",
- json={
- "folder_paths": [f1.full_path],
- "folder_hashes": [f1.hash],
- },
- )
- data = await response.get_json()
-
- assert response.status_code == 200
- assert data["deleted"] == [f1.full_path]
- assert data["hashes"] == [f1.hash]
-
- @pytest.mark.parametrize(
- "reverse",
- [True, False],
- )
- async def test_complex_delete(self, client, reverse):
- # Assuming the hashes are already known
- f1 = Folder.from_path(self.tmp_path / "complex")
- f2 = Folder.from_path(self.tmp_path / "complex" / "nested")
-
- folder_paths = [f1.full_path, f2.full_path]
- folder_hashes = [f1.hash, f2.hash]
-
- if reverse:
- folder_paths.reverse()
- folder_hashes.reverse()
-
- response = await client.delete(
- "/api_v1/inbox/delete",
- json={"folder_paths": folder_paths, "folder_hashes": folder_hashes},
- )
- data = await response.get_json()
-
- assert response.status_code == 200
- assert data["deleted"]
- assert len(data["deleted"]) == 2
- for deleted in data["deleted"]:
- assert deleted in folder_paths
- for deleted_hash in data["hashes"]:
- assert deleted_hash in folder_hashes
-
- async def test_dedupe_delete(self, client):
- # Assuming the hashes are already known
- f1 = Folder.from_path(self.tmp_path / "basic")
- f2 = Folder.from_path(self.tmp_path / "complex")
-
- folder_paths = [f1.full_path, f2.full_path, f1.full_path]
- folder_hashes = [f1.hash, f2.hash, f1.hash]
-
- response = await client.delete(
- "/api_v1/inbox/delete",
- json={"folder_paths": folder_paths, "folder_hashes": folder_hashes},
- )
- data = await response.get_json()
-
- assert response.status_code == 200
- assert data["deleted"]
- assert len(data["deleted"]) == 2
-
- async def test_invalid_hash(self, client):
- # Assuming the hashes are already known
- f1 = Folder.from_path(self.tmp_path / "basic")
-
- response = await client.delete(
- "/api_v1/inbox/delete",
- json={
- "folder_paths": [f1.full_path],
- "folder_hashes": ["invalid_hash"],
- },
- )
- data = await response.get_json()
-
- assert response.status_code == 400
- assert data["type"] == "InvalidUsageException"
- assert (
- data["message"]
- == "Folder hash does not match the current folder hash! Please refresh your hashes before deleting!"
- )
+import os
+import shutil
+from pathlib import Path
+
+import pytest
+
+from beets_flask.disk import Folder
+
+from ..test_flows import SendStatusMockMixin
+
+
+class TestDeleteEndpoint(SendStatusMockMixin):
+ tmp_path: Path
+
+ @pytest.fixture(autouse=True)
+ def create_folder(self, tmp_path):
+ # Create some folders
+ os.makedirs(tmp_path / "basic")
+ os.makedirs(tmp_path / "complex")
+ os.makedirs(tmp_path / "complex" / "nested")
+ with open(tmp_path / "basic" / "file1.txt", "w") as f:
+ f.write("Hello World")
+ with open(tmp_path / "complex" / "file2.txt", "w") as f:
+ f.write("Hello World 12")
+ with open(tmp_path / "complex" / "nested" / "file3.txt", "w") as f:
+ f.write("Hello World 123")
+
+ self.tmp_path = tmp_path
+
+ yield
+
+ # Cleanup after the test
+ shutil.rmtree(tmp_path / "basic", ignore_errors=True)
+ shutil.rmtree(tmp_path / "test2", ignore_errors=True)
+
+ async def test_basic_delete(self, client):
+ # Assuming the hashes are already known
+ f1 = Folder.from_path(self.tmp_path / "basic")
+
+ response = await client.delete(
+ "/api_v1/inbox/delete",
+ json={
+ "folder_paths": [f1.full_path],
+ "folder_hashes": [f1.hash],
+ },
+ )
+ data = await response.get_json()
+
+ assert response.status_code == 200
+ assert data["deleted"] == [f1.full_path]
+ assert data["hashes"] == [f1.hash]
+
+ @pytest.mark.parametrize(
+ "reverse",
+ [True, False],
+ )
+ async def test_complex_delete(self, client, reverse):
+ # Assuming the hashes are already known
+ f1 = Folder.from_path(self.tmp_path / "complex")
+ f2 = Folder.from_path(self.tmp_path / "complex" / "nested")
+
+ folder_paths = [f1.full_path, f2.full_path]
+ folder_hashes = [f1.hash, f2.hash]
+
+ if reverse:
+ folder_paths.reverse()
+ folder_hashes.reverse()
+
+ response = await client.delete(
+ "/api_v1/inbox/delete",
+ json={"folder_paths": folder_paths, "folder_hashes": folder_hashes},
+ )
+ data = await response.get_json()
+
+ assert response.status_code == 200
+ assert data["deleted"]
+ assert len(data["deleted"]) == 2
+ for deleted in data["deleted"]:
+ assert deleted in folder_paths
+ for deleted_hash in data["hashes"]:
+ assert deleted_hash in folder_hashes
+
+ async def test_dedupe_delete(self, client):
+ # Assuming the hashes are already known
+ f1 = Folder.from_path(self.tmp_path / "basic")
+ f2 = Folder.from_path(self.tmp_path / "complex")
+
+ folder_paths = [f1.full_path, f2.full_path, f1.full_path]
+ folder_hashes = [f1.hash, f2.hash, f1.hash]
+
+ response = await client.delete(
+ "/api_v1/inbox/delete",
+ json={"folder_paths": folder_paths, "folder_hashes": folder_hashes},
+ )
+ data = await response.get_json()
+
+ assert response.status_code == 200
+ assert data["deleted"]
+ assert len(data["deleted"]) == 2
+
+ async def test_invalid_hash(self, client):
+ # Assuming the hashes are already known
+ f1 = Folder.from_path(self.tmp_path / "basic")
+
+ response = await client.delete(
+ "/api_v1/inbox/delete",
+ json={
+ "folder_paths": [f1.full_path],
+ "folder_hashes": ["invalid_hash"],
+ },
+ )
+ data = await response.get_json()
+
+ assert response.status_code == 400
+ assert data["type"] == "InvalidUsageException"
+ assert (
+ data["message"]
+ == "Folder hash does not match the current folder hash! Please refresh your hashes before deleting!"
+ )
diff --git a/backend/tests/integration/test_routes/test_library.py b/backend/tests/integration/test_routes/test_library.py
index d660685a..d397bb97 100644
--- a/backend/tests/integration/test_routes/test_library.py
+++ b/backend/tests/integration/test_routes/test_library.py
@@ -1,413 +1,413 @@
-"""
-Currently still requires a beets library with some content in
-the default location of the user.
-"""
-
-from unittest import mock
-from urllib.parse import quote_plus
-
-import pytest
-from beets.library import Album
-from quart.typing import TestClientProtocol as Client
-
-from tests.conftest import beets_lib_album, beets_lib_item
-from tests.mixins.database import IsolatedBeetsLibraryMixin
-
-# ----------------------------------- Artist --------------------------------- #
-
-
-class TestArtistsEndpoint(IsolatedBeetsLibraryMixin):
- """Test class for the Albums endpoint in the API.
-
- This class contains tests for retrieving albums and individual album details
- from the beets library via the API.
- """
-
- artists = ["Basstripper", "Beta", "Foo; Bar,Baz"]
- expected_artists = [
- "Basstripper",
- "Beta",
- "Foo",
- "Bar",
- "Baz",
- ] # Artists should be split by semicolon
-
- _albums: list[Album] = []
-
- @pytest.fixture(autouse=True)
- def albums(self): # type: ignore
- """Fixture to add albums to the beets library before running tests."""
- if len(self._albums) == len(self.artists):
- # If albums are already added, skip adding them again
- return
-
- for artist in self.artists:
- a = beets_lib_album(albumartist=artist)
- self.beets_lib.add(a)
- self.beets_lib.add(beets_lib_item(album_id=a.id, artist=artist))
- self._albums.append(a)
-
- async def test_get_artists(self, client: Client):
- """Test the GET request to retrieve all albums by a specific artist.
-
- Asserts:
- - The response status code is 200 for each artist.
- - The returned data artist matches the requested artist.
- """
-
- response = await client.get("/api_v1/library/artists/")
- data = await response.get_json()
- assert response.status_code == 200, "Response status code is not 200"
-
- # Should return one entry for each artist, additionally seperators
- # in artist names should also be handled correctly
- for d in data:
- assert d["artist"] in self.expected_artists, (
- f"Artist {d['artist']} not in expected artists {self.expected_artists}"
- )
-
- async def test_get_single_artist(
- self,
- client: Client,
- ):
- """Test the GET request to retrieve all albums by a specific artist.
-
- Asserts:
- - The response status code is 200 for each artist.
- - The returned data artist matches the requested artist.
- """
- for artist in self.expected_artists:
- # Encode the artist name to handle special characters
- encoded_artist = quote_plus(artist)
- response = await client.get(f"/api_v1/library/artists/{encoded_artist}")
- data = await response.get_json()
- assert "artist" in data, "Artist key is not in the response"
- assert data["artist"] == artist, "Artist does not match requested artist"
-
- async def test_get_artist(self, client: Client):
- """Test the GET request to retrieve a specific artist by its ID.
-
- Asserts:
- - The response status code is 200 for each artist.
- - The returned data ID matches the requested artist ID.
- """
-
- for artist in self.artists:
- response = await client.get(f"/api_v1/library/artist/{artist}/albums")
- data = await response.get_json()
- assert response.status_code == 200, "Response status code is not 200"
- # We added one album and one item for each artist
- assert len(data) == 1, "Data length is not 1"
- assert data[0]["albumartist"] == artist, "Data artist does not match artist"
-
- async def test_separator(self, client: Client):
- """Test the GET request to retrieve a specific artist with a separator in the name.
-
- Should return the artist even if the name contains a separator.
- """
- # Test with a separator in the artist name
- response = await client.get("/api_v1/library/artists/Foo; Bar,Baz")
- data = await response.get_json()
- assert response.status_code == 200, "Response status code is not 200"
- assert data["artist"] == "Foo; Bar,Baz", (
- "Data artist does not match requested artist with separator"
- )
-
- # Order of the artists should not matter, so we can also test with a different order
- response = await client.get("/api_v1/library/artists/Foo; Baz")
- data = await response.get_json()
- assert response.status_code == 200, "Response status code is not 200"
- assert data["artist"] == "Foo; Baz", (
- "Data artist does not match requested artist with separator"
- )
-
- # Should not be found if one of the joined artists is not in the library
- response = await client.get("/api_v1/library/artists/Foo; Bar, NotInLibrary")
- assert response.status_code == 404, "Response status code is not 404"
-
- async def test_no_separators(self, client: Client):
- # Validate that the logic works if no separators are defined
- from beets_flask.config.beets_config import refresh_config
-
- config = refresh_config()
- config["gui"]["artist_separators"] = []
-
- # Mock artist_seperators
- with mock.patch(
- "beets_flask.server.routes.library.artists.ARTIST_SEPARATORS",
- [],
- ):
- response = await client.get("/api_v1/library/artists/Foo; Bar")
- data = await response.get_json()
- assert response.status_code == 200, "Response status code is not 200"
- assert data["artist"] == "Foo; Bar,Baz", (
- "Data artist does not match requested artist without separators"
- )
-
-
-# ----------------------------------- album ---------------------------------- #
-
-
-class TestAlbumEndpoints(IsolatedBeetsLibraryMixin):
- """Test class for the Albums endpoint in the API.
-
- This class contains tests for retrieving albums and individual album details
- from the beets library via the API.
- """
-
- @pytest.fixture(autouse=True)
- def albums(self): # type: ignore
- """Fixture to add albums to the beets library before running tests."""
- a = beets_lib_album(artist="Basstripper", album="Bass")
- self.beets_lib.add(a)
- self.beets_lib.add(beets_lib_item(artist="Beta", album_id=a.id))
-
- @pytest.mark.asyncio
- async def test_get_album(
- self,
- client: Client,
- ):
- """Test the GET request to retrieve a specific album by its ID.
-
- Asserts:
- - The response status code is 200 for each album.
- - The returned data ID matches the requested album ID.
- """
- albums = self.beets_lib.albums()
- for album in albums:
- response = await client.get(f"/api_v1/library/album/{album.id}")
- data = await response.get_json()
- assert response.status_code == 200, "Response status code is not 200"
- assert data["id"] == album.id, "Data id does not match album id"
-
-
-class TestAlbumsPagination(IsolatedBeetsLibraryMixin):
- """Test if pagination of albums works as expected"""
-
- @pytest.fixture(autouse=True)
- def albums(self): # type: ignore
- """Fixture to add albums to the beets library before running tests."""
- nAlbums = 100
- if len(self.beets_lib.albums()) == 0:
- for i in range(nAlbums):
- artist = "Even" if i % 2 == 0 else f"Odd"
- a = beets_lib_album(albumartist=f"{artist}", album=f"Album {i}")
- self.beets_lib.add(a)
- self.beets_lib.add(beets_lib_item(artist=f"{artist}", album_id=a.id))
-
- assert len(self.beets_lib.albums()) == nAlbums
-
- async def test_get_albums(self, client: Client):
- """Test the GET request to retrieve all albums with pagination.
-
- Asserts:
- - The response status code is 200.
- - The returned data contains the expected number of albums.
- - The next cursor is provided for pagination.
- """
- response = await client.get("/api_v1/library/albums/?n_items=10")
- data = await response.get_json()
- assert response.status_code == 200, "Response status code is not 200"
- assert "albums" in data, "Items are not provided in the response"
- assert len(data["albums"]) == 10, "Data length is not 10"
- assert "next" in data, "Next cursor is not provided"
- assert "total" in data, "Total count is not provided"
- assert data["total"] == 100, "Total count does not match expected value"
-
- async def test_iter_cusor(self, client: Client):
- """Test the GET request to retrieve all albums with pagination using cursor.
-
- Asserts:
- - The response status code is 200.
- - The returned data contains the expected number of albums.
- - The next cursor is provided for pagination.
- """
-
- next_url: str | None = "/api_v1/library/albums/?n_items=10"
- albums = []
- total_albums = len(self.beets_lib.albums())
- while next_url:
- response = await client.get(next_url)
- data = await response.get_json()
- assert response.status_code == 200, "Response status code is not 200"
- assert "albums" in data, "Items are not provided in the response"
- assert "next" in data, "Next cursor is not provided"
- assert data["total"] == total_albums, (
- "Total count does not match expected value"
- )
- albums.extend(data["albums"])
- next_url = data["next"] if "next" in data else None
-
- assert len(albums) == len(self.beets_lib.albums()), (
- "Total number of albums does not match expected value"
- )
-
- @pytest.mark.parametrize(
- "order_by, order_dir",
- [
- ("albumartist", "ASC"),
- ("albumartist", "DESC"),
- ],
- )
- async def test_ordering(self, client: Client, order_by, order_dir):
- """Test the GET request to retrieve all albums with ordering.
-
- Asserts:
- - The response status code is 200.
- - The returned data contains the expected number of albums.
- - The albums are ordered by artist and album name.
- """
- next_url: str | None = (
- f"/api_v1/library/albums/?n_items=10&order_by={order_by}&order_dir={order_dir}"
- )
-
- albums = []
- while next_url:
- response = await client.get(next_url)
- data = await response.get_json()
- assert response.status_code == 200, "Response status code is not 200"
- assert "albums" in data, "Items are not provided in the response"
- assert "next" in data, "Next cursor is not provided"
- albums.extend(data["albums"])
- next_url = data["next"] if "next" in data else None
- assert len(albums) == len(self.beets_lib.albums()), (
- "Total number of albums does not match expected value"
- )
-
- # Assert ordering
- for i in range(1, len(albums)):
- if order_dir == "ASC":
- assert albums[i][order_by] >= albums[i - 1][order_by], (
- f"Albums are not ordered by {order_by} in ascending order"
- )
- else:
- assert albums[i][order_by] <= albums[i - 1][order_by], (
- f"Albums are not ordered by {order_by} in descending order"
- )
-
- async def test_with_query(
- self,
- client: Client,
- ):
- """Test the GET request to retrieve all albums with a query.
-
- Asserts:
- - The response status code is 200.
- - The returned data contains the expected number of albums.
- - The albums match the query.
- """
- response = await client.get(f"/api_v1/library/albums/Even?n_items=100")
- data = await response.get_json()
- assert response.status_code == 200, "Response status code is not 200"
- assert "albums" in data, "Items are not provided in the response"
- assert len(data["albums"]) == 50
-
-
-# ----------------------------------- Items ---------------------------------- #
-
-
-class TestItemEndpoint(IsolatedBeetsLibraryMixin):
- """Test class for the Items endpoint in the API.
-
- This class contains tests for retrieving items and individual item details
- from the beets library via the API.
- """
-
- @pytest.fixture(autouse=True)
- def items(self): # type: ignore
- """Fixture to add items to the beets library before running tests."""
- self.beets_lib.add(beets_lib_item(artist="Basstripper", album="Bass"))
- self.beets_lib.add(beets_lib_item(artist="Beta", album="Alpha"))
-
- @pytest.mark.asyncio
- async def test_get_item(self, client: Client):
- """Test the GET request to retrieve a specific item by its ID.
-
- Asserts:
- - The response status code is 200 for each item.
- - The returned data ID matches the requested item ID.
- """
- items = self.beets_lib.items()
- for item in items:
- response = await client.get(f"/api_v1/library/item/{item.id}")
- data = await response.get_json()
- assert response.status_code == 200, "Response status code is not 200"
- assert data["id"] == item.id, "Data id does not match item id"
-
-
-class TestItemsPagination(IsolatedBeetsLibraryMixin):
- """Test if pagination of items works as expected"""
-
- @pytest.fixture(autouse=True)
- def items(self): # type: ignore
- """Fixture to add items to the beets library before running tests."""
- nItems = 100
- if len(self.beets_lib.items()) == 0:
- for i in range(nItems):
- artist = "Even" if i % 2 == 0 else f"Odd"
- self.beets_lib.add(
- beets_lib_item(artist=f"{artist}", album=f"Album {i}")
- )
-
- assert len(self.beets_lib.items()) == nItems
-
- async def test_get_items(self, client: Client):
- """Test the GET request to retrieve all items with pagination.
-
- Asserts:
- - The response status code is 200.
- - The returned data contains the expected number of items.
- - The next cursor is provided for pagination.
- """
- response = await client.get("/api_v1/library/items/?n_items=10")
- data = await response.get_json()
- assert response.status_code == 200, "Response status code is not 200"
- assert "items" in data, "Items are not provided in the response"
- assert len(data["items"]) == 10, "Data length is not 10"
- assert "next" in data, "Next cursor is not provided"
- assert "total" in data, "Total count is not provided"
- assert data["total"] == 100, "Total count does not match expected value"
-
-
-# ---------------------------------------------------------------------------- #
-# Test art #
-# ---------------------------------------------------------------------------- #
-
-
-@pytest.mark.skip("Test is skipped because it requires a beets library with art. TODO")
-class TestArtEndpoint(IsolatedBeetsLibraryMixin):
- """Test class for the Art endpoint in the API.
-
- This class contains tests for retrieving art for items and albums
- from the beets library via the API.
- """
-
- @pytest.fixture(autouse=True)
- def items(self): # type: ignore
- """Fixture to add items to the beets library before running tests."""
- self.beets_lib.add(beets_lib_item(artist="Basstripper", album="Bass"))
- self.beets_lib.add(beets_lib_album(artist="Beta", album="Alpha"))
-
- async def test_get_art(
- self,
- client: Client,
- ):
- """Test the GET request to retrieve art for an item and an album.
-
- Asserts:
- - The response status code is 200 for each item and album.
- """
-
- items = self.beets_lib.items()
- for item in items:
- response = await client.get(f"/api_v1/library/item/{item.id}/art")
- data = await response.get_json()
- print(data)
- assert response.status_code == 200
-
- albums = self.beets_lib.albums()
-
- for album in albums:
- response = await client.get(f"/api_v1/library/album/{album.id}/art")
- data = await response.get_json()
- assert response.status_code == 200
+"""
+Currently still requires a beets library with some content in
+the default location of the user.
+"""
+
+from unittest import mock
+from urllib.parse import quote_plus
+
+import pytest
+from beets.library import Album
+from quart.typing import TestClientProtocol as Client
+
+from tests.conftest import beets_lib_album, beets_lib_item
+from tests.mixins.database import IsolatedBeetsLibraryMixin
+
+# ----------------------------------- Artist --------------------------------- #
+
+
+class TestArtistsEndpoint(IsolatedBeetsLibraryMixin):
+ """Test class for the Albums endpoint in the API.
+
+ This class contains tests for retrieving albums and individual album details
+ from the beets library via the API.
+ """
+
+ artists = ["Basstripper", "Beta", "Foo; Bar,Baz"]
+ expected_artists = [
+ "Basstripper",
+ "Beta",
+ "Foo",
+ "Bar",
+ "Baz",
+ ] # Artists should be split by semicolon
+
+ _albums: list[Album] = []
+
+ @pytest.fixture(autouse=True)
+ def albums(self): # type: ignore
+ """Fixture to add albums to the beets library before running tests."""
+ if len(self._albums) == len(self.artists):
+ # If albums are already added, skip adding them again
+ return
+
+ for artist in self.artists:
+ a = beets_lib_album(albumartist=artist)
+ self.beets_lib.add(a)
+ self.beets_lib.add(beets_lib_item(album_id=a.id, artist=artist))
+ self._albums.append(a)
+
+ async def test_get_artists(self, client: Client):
+ """Test the GET request to retrieve all albums by a specific artist.
+
+ Asserts:
+ - The response status code is 200 for each artist.
+ - The returned data artist matches the requested artist.
+ """
+
+ response = await client.get("/api_v1/library/artists/")
+ data = await response.get_json()
+ assert response.status_code == 200, "Response status code is not 200"
+
+ # Should return one entry for each artist, additionally seperators
+ # in artist names should also be handled correctly
+ for d in data:
+ assert d["artist"] in self.expected_artists, (
+ f"Artist {d['artist']} not in expected artists {self.expected_artists}"
+ )
+
+ async def test_get_single_artist(
+ self,
+ client: Client,
+ ):
+ """Test the GET request to retrieve all albums by a specific artist.
+
+ Asserts:
+ - The response status code is 200 for each artist.
+ - The returned data artist matches the requested artist.
+ """
+ for artist in self.expected_artists:
+ # Encode the artist name to handle special characters
+ encoded_artist = quote_plus(artist)
+ response = await client.get(f"/api_v1/library/artists/{encoded_artist}")
+ data = await response.get_json()
+ assert "artist" in data, "Artist key is not in the response"
+ assert data["artist"] == artist, "Artist does not match requested artist"
+
+ async def test_get_artist(self, client: Client):
+ """Test the GET request to retrieve a specific artist by its ID.
+
+ Asserts:
+ - The response status code is 200 for each artist.
+ - The returned data ID matches the requested artist ID.
+ """
+
+ for artist in self.artists:
+ response = await client.get(f"/api_v1/library/artist/{artist}/albums")
+ data = await response.get_json()
+ assert response.status_code == 200, "Response status code is not 200"
+ # We added one album and one item for each artist
+ assert len(data) == 1, "Data length is not 1"
+ assert data[0]["albumartist"] == artist, "Data artist does not match artist"
+
+ async def test_separator(self, client: Client):
+ """Test the GET request to retrieve a specific artist with a separator in the name.
+
+ Should return the artist even if the name contains a separator.
+ """
+ # Test with a separator in the artist name
+ response = await client.get("/api_v1/library/artists/Foo; Bar,Baz")
+ data = await response.get_json()
+ assert response.status_code == 200, "Response status code is not 200"
+ assert data["artist"] == "Foo; Bar,Baz", (
+ "Data artist does not match requested artist with separator"
+ )
+
+ # Order of the artists should not matter, so we can also test with a different order
+ response = await client.get("/api_v1/library/artists/Foo; Baz")
+ data = await response.get_json()
+ assert response.status_code == 200, "Response status code is not 200"
+ assert data["artist"] == "Foo; Baz", (
+ "Data artist does not match requested artist with separator"
+ )
+
+ # Should not be found if one of the joined artists is not in the library
+ response = await client.get("/api_v1/library/artists/Foo; Bar, NotInLibrary")
+ assert response.status_code == 404, "Response status code is not 404"
+
+ async def test_no_separators(self, client: Client):
+ # Validate that the logic works if no separators are defined
+ from beets_flask.config.beets_config import refresh_config
+
+ config = refresh_config()
+ config["gui"]["artist_separators"] = []
+
+ # Mock artist_seperators
+ with mock.patch(
+ "beets_flask.server.routes.library.artists.ARTIST_SEPARATORS",
+ [],
+ ):
+ response = await client.get("/api_v1/library/artists/Foo; Bar")
+ data = await response.get_json()
+ assert response.status_code == 200, "Response status code is not 200"
+ assert data["artist"] == "Foo; Bar,Baz", (
+ "Data artist does not match requested artist without separators"
+ )
+
+
+# ----------------------------------- album ---------------------------------- #
+
+
+class TestAlbumEndpoints(IsolatedBeetsLibraryMixin):
+ """Test class for the Albums endpoint in the API.
+
+ This class contains tests for retrieving albums and individual album details
+ from the beets library via the API.
+ """
+
+ @pytest.fixture(autouse=True)
+ def albums(self): # type: ignore
+ """Fixture to add albums to the beets library before running tests."""
+ a = beets_lib_album(artist="Basstripper", album="Bass")
+ self.beets_lib.add(a)
+ self.beets_lib.add(beets_lib_item(artist="Beta", album_id=a.id))
+
+ @pytest.mark.asyncio
+ async def test_get_album(
+ self,
+ client: Client,
+ ):
+ """Test the GET request to retrieve a specific album by its ID.
+
+ Asserts:
+ - The response status code is 200 for each album.
+ - The returned data ID matches the requested album ID.
+ """
+ albums = self.beets_lib.albums()
+ for album in albums:
+ response = await client.get(f"/api_v1/library/album/{album.id}")
+ data = await response.get_json()
+ assert response.status_code == 200, "Response status code is not 200"
+ assert data["id"] == album.id, "Data id does not match album id"
+
+
+class TestAlbumsPagination(IsolatedBeetsLibraryMixin):
+ """Test if pagination of albums works as expected"""
+
+ @pytest.fixture(autouse=True)
+ def albums(self): # type: ignore
+ """Fixture to add albums to the beets library before running tests."""
+ nAlbums = 100
+ if len(self.beets_lib.albums()) == 0:
+ for i in range(nAlbums):
+ artist = "Even" if i % 2 == 0 else f"Odd"
+ a = beets_lib_album(albumartist=f"{artist}", album=f"Album {i}")
+ self.beets_lib.add(a)
+ self.beets_lib.add(beets_lib_item(artist=f"{artist}", album_id=a.id))
+
+ assert len(self.beets_lib.albums()) == nAlbums
+
+ async def test_get_albums(self, client: Client):
+ """Test the GET request to retrieve all albums with pagination.
+
+ Asserts:
+ - The response status code is 200.
+ - The returned data contains the expected number of albums.
+ - The next cursor is provided for pagination.
+ """
+ response = await client.get("/api_v1/library/albums/?n_items=10")
+ data = await response.get_json()
+ assert response.status_code == 200, "Response status code is not 200"
+ assert "albums" in data, "Items are not provided in the response"
+ assert len(data["albums"]) == 10, "Data length is not 10"
+ assert "next" in data, "Next cursor is not provided"
+ assert "total" in data, "Total count is not provided"
+ assert data["total"] == 100, "Total count does not match expected value"
+
+ async def test_iter_cusor(self, client: Client):
+ """Test the GET request to retrieve all albums with pagination using cursor.
+
+ Asserts:
+ - The response status code is 200.
+ - The returned data contains the expected number of albums.
+ - The next cursor is provided for pagination.
+ """
+
+ next_url: str | None = "/api_v1/library/albums/?n_items=10"
+ albums = []
+ total_albums = len(self.beets_lib.albums())
+ while next_url:
+ response = await client.get(next_url)
+ data = await response.get_json()
+ assert response.status_code == 200, "Response status code is not 200"
+ assert "albums" in data, "Items are not provided in the response"
+ assert "next" in data, "Next cursor is not provided"
+ assert data["total"] == total_albums, (
+ "Total count does not match expected value"
+ )
+ albums.extend(data["albums"])
+ next_url = data["next"] if "next" in data else None
+
+ assert len(albums) == len(self.beets_lib.albums()), (
+ "Total number of albums does not match expected value"
+ )
+
+ @pytest.mark.parametrize(
+ "order_by, order_dir",
+ [
+ ("albumartist", "ASC"),
+ ("albumartist", "DESC"),
+ ],
+ )
+ async def test_ordering(self, client: Client, order_by, order_dir):
+ """Test the GET request to retrieve all albums with ordering.
+
+ Asserts:
+ - The response status code is 200.
+ - The returned data contains the expected number of albums.
+ - The albums are ordered by artist and album name.
+ """
+ next_url: str | None = (
+ f"/api_v1/library/albums/?n_items=10&order_by={order_by}&order_dir={order_dir}"
+ )
+
+ albums = []
+ while next_url:
+ response = await client.get(next_url)
+ data = await response.get_json()
+ assert response.status_code == 200, "Response status code is not 200"
+ assert "albums" in data, "Items are not provided in the response"
+ assert "next" in data, "Next cursor is not provided"
+ albums.extend(data["albums"])
+ next_url = data["next"] if "next" in data else None
+ assert len(albums) == len(self.beets_lib.albums()), (
+ "Total number of albums does not match expected value"
+ )
+
+ # Assert ordering
+ for i in range(1, len(albums)):
+ if order_dir == "ASC":
+ assert albums[i][order_by] >= albums[i - 1][order_by], (
+ f"Albums are not ordered by {order_by} in ascending order"
+ )
+ else:
+ assert albums[i][order_by] <= albums[i - 1][order_by], (
+ f"Albums are not ordered by {order_by} in descending order"
+ )
+
+ async def test_with_query(
+ self,
+ client: Client,
+ ):
+ """Test the GET request to retrieve all albums with a query.
+
+ Asserts:
+ - The response status code is 200.
+ - The returned data contains the expected number of albums.
+ - The albums match the query.
+ """
+ response = await client.get(f"/api_v1/library/albums/Even?n_items=100")
+ data = await response.get_json()
+ assert response.status_code == 200, "Response status code is not 200"
+ assert "albums" in data, "Items are not provided in the response"
+ assert len(data["albums"]) == 50
+
+
+# ----------------------------------- Items ---------------------------------- #
+
+
+class TestItemEndpoint(IsolatedBeetsLibraryMixin):
+ """Test class for the Items endpoint in the API.
+
+ This class contains tests for retrieving items and individual item details
+ from the beets library via the API.
+ """
+
+ @pytest.fixture(autouse=True)
+ def items(self): # type: ignore
+ """Fixture to add items to the beets library before running tests."""
+ self.beets_lib.add(beets_lib_item(artist="Basstripper", album="Bass"))
+ self.beets_lib.add(beets_lib_item(artist="Beta", album="Alpha"))
+
+ @pytest.mark.asyncio
+ async def test_get_item(self, client: Client):
+ """Test the GET request to retrieve a specific item by its ID.
+
+ Asserts:
+ - The response status code is 200 for each item.
+ - The returned data ID matches the requested item ID.
+ """
+ items = self.beets_lib.items()
+ for item in items:
+ response = await client.get(f"/api_v1/library/item/{item.id}")
+ data = await response.get_json()
+ assert response.status_code == 200, "Response status code is not 200"
+ assert data["id"] == item.id, "Data id does not match item id"
+
+
+class TestItemsPagination(IsolatedBeetsLibraryMixin):
+ """Test if pagination of items works as expected"""
+
+ @pytest.fixture(autouse=True)
+ def items(self): # type: ignore
+ """Fixture to add items to the beets library before running tests."""
+ nItems = 100
+ if len(self.beets_lib.items()) == 0:
+ for i in range(nItems):
+ artist = "Even" if i % 2 == 0 else f"Odd"
+ self.beets_lib.add(
+ beets_lib_item(artist=f"{artist}", album=f"Album {i}")
+ )
+
+ assert len(self.beets_lib.items()) == nItems
+
+ async def test_get_items(self, client: Client):
+ """Test the GET request to retrieve all items with pagination.
+
+ Asserts:
+ - The response status code is 200.
+ - The returned data contains the expected number of items.
+ - The next cursor is provided for pagination.
+ """
+ response = await client.get("/api_v1/library/items/?n_items=10")
+ data = await response.get_json()
+ assert response.status_code == 200, "Response status code is not 200"
+ assert "items" in data, "Items are not provided in the response"
+ assert len(data["items"]) == 10, "Data length is not 10"
+ assert "next" in data, "Next cursor is not provided"
+ assert "total" in data, "Total count is not provided"
+ assert data["total"] == 100, "Total count does not match expected value"
+
+
+# ---------------------------------------------------------------------------- #
+# Test art #
+# ---------------------------------------------------------------------------- #
+
+
+@pytest.mark.skip("Test is skipped because it requires a beets library with art. TODO")
+class TestArtEndpoint(IsolatedBeetsLibraryMixin):
+ """Test class for the Art endpoint in the API.
+
+ This class contains tests for retrieving art for items and albums
+ from the beets library via the API.
+ """
+
+ @pytest.fixture(autouse=True)
+ def items(self): # type: ignore
+ """Fixture to add items to the beets library before running tests."""
+ self.beets_lib.add(beets_lib_item(artist="Basstripper", album="Bass"))
+ self.beets_lib.add(beets_lib_album(artist="Beta", album="Alpha"))
+
+ async def test_get_art(
+ self,
+ client: Client,
+ ):
+ """Test the GET request to retrieve art for an item and an album.
+
+ Asserts:
+ - The response status code is 200 for each item and album.
+ """
+
+ items = self.beets_lib.items()
+ for item in items:
+ response = await client.get(f"/api_v1/library/item/{item.id}/art")
+ data = await response.get_json()
+ print(data)
+ assert response.status_code == 200
+
+ albums = self.beets_lib.albums()
+
+ for album in albums:
+ response = await client.get(f"/api_v1/library/album/{album.id}/art")
+ data = await response.get_json()
+ assert response.status_code == 200
diff --git a/backend/tests/integration/test_watchdog.py b/backend/tests/integration/test_watchdog.py
index 4138606a..bb7806bc 100644
--- a/backend/tests/integration/test_watchdog.py
+++ b/backend/tests/integration/test_watchdog.py
@@ -1,87 +1,87 @@
-import asyncio
-import os
-from pathlib import Path
-
-import pytest
-
-from beets_flask.config import get_config
-from beets_flask.config.beets_config import refresh_config
-from beets_flask.watchdog.inbox import InboxHandler, register_inboxes
-
-
-@pytest.fixture(scope="function")
-def preview_autotag(tmpdir_factory):
- """
- Setup for the watchdog tests.
- This fixture will run before and after all tests in this module.
- """
- config = get_config()
- config["gui"]["inbox"]["folders"] = {
- "inbox1": {
- "path": tmpdir_factory.mktemp("inbox").strpath,
- "autotag": "preview",
- },
- }
- yield
- refresh_config()
-
-
-from unittest import mock
-
-
-@pytest.fixture(scope="function")
-def mp_en():
- """
- A fixture to mock the `enqueue` function in the `invoker` module.
- This allows us to test the behavior of the inbox handler without
- actually enqueuing tasks.
- """
-
- calls = []
-
- with mock.patch(
- "beets_flask.watchdog.inbox.auto_tag",
- lambda *args, **kwargs: calls.append((*args, *kwargs)),
- ):
- yield calls
-
-
-async def test_watchdog(preview_autotag, mp_en):
- """Start watching the inbox folder"""
-
- config = get_config()
- print("foo")
- inbox_path = Path(str(config["gui"]["inbox"]["folders"]["inbox1"]["path"].get()))
- inbox_autotag = config["gui"]["inbox"]["folders"]["inbox1"]["autotag"].get()
-
- assert inbox_path.is_dir(), "Inbox path should be a directory"
- assert inbox_autotag == "preview", "Inbox autotag should be set to 'preview'"
-
- watchdog = register_inboxes(0.1, 0.5) # timeout=1s, debounce=30s
- assert watchdog is not None, "Watchdog should be initialized"
- assert watchdog._observer.is_alive(), "Watchdog should be running"
-
- # Touch file in inbox
- os.makedirs(inbox_path / "album", exist_ok=True)
- (inbox_path / "album" / "test.mp3").touch()
-
- h = watchdog._handler
- assert isinstance(h, InboxHandler), (
- "Handler should be an instance of AIOEventHandler"
- )
- await asyncio.sleep(0.12) # Allow time for the observer to start task
- task = list(h.debounce.values())[0]
- assert task is not None, "Debounce should have a task after touching file"
- # Check task is running
- assert not task.done(), "Task should not be done immediately after touching file"
-
- # Touch again and check that task is cancelled
- (inbox_path / "album" / "test.mp3").touch()
- await asyncio.sleep(0.12) # Allow debounce time
- assert task.cancelled(), "Task should be cancelled by new incoming ones"
-
- # Check that the task is not running anymore
- task_2 = list(h.debounce.values())[0]
- await asyncio.sleep(1)
- assert task_2.done(), "Task should be done after debounce time"
- assert mp_en[0][0] == (inbox_path / "album").resolve()
+import asyncio
+import os
+from pathlib import Path
+
+import pytest
+
+from beets_flask.config import get_config
+from beets_flask.config.beets_config import refresh_config
+from beets_flask.watchdog.inbox import InboxHandler, register_inboxes
+
+
+@pytest.fixture(scope="function")
+def preview_autotag(tmpdir_factory):
+ """
+ Setup for the watchdog tests.
+ This fixture will run before and after all tests in this module.
+ """
+ config = get_config()
+ config["gui"]["inbox"]["folders"] = {
+ "inbox1": {
+ "path": tmpdir_factory.mktemp("inbox").strpath,
+ "autotag": "preview",
+ },
+ }
+ yield
+ refresh_config()
+
+
+from unittest import mock
+
+
+@pytest.fixture(scope="function")
+def mp_en():
+ """
+ A fixture to mock the `enqueue` function in the `invoker` module.
+ This allows us to test the behavior of the inbox handler without
+ actually enqueuing tasks.
+ """
+
+ calls = []
+
+ with mock.patch(
+ "beets_flask.watchdog.inbox.auto_tag",
+ lambda *args, **kwargs: calls.append((*args, *kwargs)),
+ ):
+ yield calls
+
+
+async def test_watchdog(preview_autotag, mp_en):
+ """Start watching the inbox folder"""
+
+ config = get_config()
+ print("foo")
+ inbox_path = Path(str(config["gui"]["inbox"]["folders"]["inbox1"]["path"].get()))
+ inbox_autotag = config["gui"]["inbox"]["folders"]["inbox1"]["autotag"].get()
+
+ assert inbox_path.is_dir(), "Inbox path should be a directory"
+ assert inbox_autotag == "preview", "Inbox autotag should be set to 'preview'"
+
+ watchdog = register_inboxes(0.1, 0.5) # timeout=1s, debounce=30s
+ assert watchdog is not None, "Watchdog should be initialized"
+ assert watchdog._observer.is_alive(), "Watchdog should be running"
+
+ # Touch file in inbox
+ os.makedirs(inbox_path / "album", exist_ok=True)
+ (inbox_path / "album" / "test.mp3").touch()
+
+ h = watchdog._handler
+ assert isinstance(h, InboxHandler), (
+ "Handler should be an instance of AIOEventHandler"
+ )
+ await asyncio.sleep(0.12) # Allow time for the observer to start task
+ task = list(h.debounce.values())[0]
+ assert task is not None, "Debounce should have a task after touching file"
+ # Check task is running
+ assert not task.done(), "Task should not be done immediately after touching file"
+
+ # Touch again and check that task is cancelled
+ (inbox_path / "album" / "test.mp3").touch()
+ await asyncio.sleep(0.12) # Allow debounce time
+ assert task.cancelled(), "Task should be cancelled by new incoming ones"
+
+ # Check that the task is not running anymore
+ task_2 = list(h.debounce.values())[0]
+ await asyncio.sleep(1)
+ assert task_2.done(), "Task should be done after debounce time"
+ assert mp_en[0][0] == (inbox_path / "album").resolve()
diff --git a/backend/tests/integration/test_websocket/conftest.py b/backend/tests/integration/test_websocket/conftest.py
index 6740c250..81d6c4fb 100644
--- a/backend/tests/integration/test_websocket/conftest.py
+++ b/backend/tests/integration/test_websocket/conftest.py
@@ -1,77 +1,77 @@
-import asyncio
-
-import pytest
-import socketio
-import uvicorn
-
-HOST = "127.0.0.1"
-PORT = 5006
-BASE_URL = f"http://{HOST}:{PORT}"
-
-
-class UvicornTestServer(uvicorn.Server):
- def __init__(
- self,
- app: socketio.ASGIApp,
- host: str = HOST,
- port: int = PORT,
- ):
- self._startup_done = asyncio.Event()
- self._serve_task: asyncio.Task | None = None
- super().__init__(config=uvicorn.Config(app, host=host, port=port))
-
- async def startup(self, sockets=None) -> None:
- """Override uvicorn startup"""
- await super().startup()
- self._startup_done.set()
-
- async def start_up(self) -> None:
- """Start up server asynchronously"""
- self._serve_task = asyncio.create_task(self.serve())
- await self._startup_done.wait()
-
- async def tear_down(self) -> None:
- """Shut down server asynchronously"""
- self.should_exit = True
- if self._serve_task:
- # Cancel the serve task
- self._serve_task.cancel()
- # Wait for all tasks to complete, ignoring any CancelledErrors
- try:
- await self._serve_task
- except asyncio.exceptions.CancelledError:
- pass
- await self.shutdown()
-
-
-@pytest.fixture
-async def fixture_ws_server(testapp):
- server = UvicornTestServer(testapp)
- try:
- await server.start_up()
- yield
- await server.tear_down()
- await server.shutdown()
- except Exception as e:
- raise e
-
- tasks = asyncio.all_tasks()
- for task in tasks:
- if task is not asyncio.current_task():
- task.cancel()
-
-
-@pytest.fixture
-async def ws_client(fixture_ws_server):
- client = socketio.AsyncClient(reconnection=False)
- await client.connect(
- BASE_URL,
- namespaces=["/test"],
- transports=["websocket"],
- )
- try:
- yield client
- finally:
- await client.disconnect()
- await client.wait()
- await client.shutdown()
+import asyncio
+
+import pytest
+import socketio
+import uvicorn
+
+HOST = "127.0.0.1"
+PORT = 5006
+BASE_URL = f"http://{HOST}:{PORT}"
+
+
+class UvicornTestServer(uvicorn.Server):
+ def __init__(
+ self,
+ app: socketio.ASGIApp,
+ host: str = HOST,
+ port: int = PORT,
+ ):
+ self._startup_done = asyncio.Event()
+ self._serve_task: asyncio.Task | None = None
+ super().__init__(config=uvicorn.Config(app, host=host, port=port))
+
+ async def startup(self, sockets=None) -> None:
+ """Override uvicorn startup"""
+ await super().startup()
+ self._startup_done.set()
+
+ async def start_up(self) -> None:
+ """Start up server asynchronously"""
+ self._serve_task = asyncio.create_task(self.serve())
+ await self._startup_done.wait()
+
+ async def tear_down(self) -> None:
+ """Shut down server asynchronously"""
+ self.should_exit = True
+ if self._serve_task:
+ # Cancel the serve task
+ self._serve_task.cancel()
+ # Wait for all tasks to complete, ignoring any CancelledErrors
+ try:
+ await self._serve_task
+ except asyncio.exceptions.CancelledError:
+ pass
+ await self.shutdown()
+
+
+@pytest.fixture
+async def fixture_ws_server(testapp):
+ server = UvicornTestServer(testapp)
+ try:
+ await server.start_up()
+ yield
+ await server.tear_down()
+ await server.shutdown()
+ except Exception as e:
+ raise e
+
+ tasks = asyncio.all_tasks()
+ for task in tasks:
+ if task is not asyncio.current_task():
+ task.cancel()
+
+
+@pytest.fixture
+async def ws_client(fixture_ws_server):
+ client = socketio.AsyncClient(reconnection=False)
+ await client.connect(
+ BASE_URL,
+ namespaces=["/test"],
+ transports=["websocket"],
+ )
+ try:
+ yield client
+ finally:
+ await client.disconnect()
+ await client.wait()
+ await client.shutdown()
diff --git a/backend/tests/integration/test_websocket/test_errors.py b/backend/tests/integration/test_websocket/test_errors.py
index ed3ffc52..aba03419 100644
--- a/backend/tests/integration/test_websocket/test_errors.py
+++ b/backend/tests/integration/test_websocket/test_errors.py
@@ -1,29 +1,29 @@
-import logging
-
-import pytest
-from socketio import AsyncClient
-
-log = logging.getLogger(__name__)
-
-
-@pytest.mark.asyncio
-async def test_ws_client(ws_client):
- assert isinstance(ws_client, AsyncClient)
- assert ws_client.sid is not None
- assert ws_client.connected is True
-
-
-@pytest.mark.asyncio
-async def test_generic_exc(ws_client: AsyncClient):
- # TODO: this needs some more thoughts, we have a more generalized
- # error handling now, we might want to adjust this for websocket
- r = await ws_client.call(
- "test_generic_exc",
- namespace="/test",
- timeout=5,
- )
-
- assert r is not None
- assert isinstance(r, dict)
- assert r["error"] == "Exception"
- assert r["message"] == "Exception message"
+import logging
+
+import pytest
+from socketio import AsyncClient
+
+log = logging.getLogger(__name__)
+
+
+@pytest.mark.asyncio
+async def test_ws_client(ws_client):
+ assert isinstance(ws_client, AsyncClient)
+ assert ws_client.sid is not None
+ assert ws_client.connected is True
+
+
+@pytest.mark.asyncio
+async def test_generic_exc(ws_client: AsyncClient):
+ # TODO: this needs some more thoughts, we have a more generalized
+ # error handling now, we might want to adjust this for websocket
+ r = await ws_client.call(
+ "test_generic_exc",
+ namespace="/test",
+ timeout=5,
+ )
+
+ assert r is not None
+ assert isinstance(r, dict)
+ assert r["error"] == "Exception"
+ assert r["message"] == "Exception message"
diff --git a/backend/tests/mixins/database.py b/backend/tests/mixins/database.py
index d7c68ef2..a436a538 100644
--- a/backend/tests/mixins/database.py
+++ b/backend/tests/mixins/database.py
@@ -1,116 +1,116 @@
-from __future__ import annotations
-
-import os
-from abc import ABC
-from collections import namedtuple
-from functools import cached_property
-from typing import TYPE_CHECKING
-from unittest import mock
-
-import pytest
-
-if TYPE_CHECKING:
- from beets_flask.importer.types import BeetsLibrary
-
-
-class IsolatedDBMixin(ABC):
- """
- A pytest mixin class to reset the database before and after ALL
- tests in this class are run.
-
- Usage:
- ```
- class TestMyFeature(IsolatedDBMixin):
- def test_something(self):
- # add to clean db
-
- def test_something_else(self):
- # db has data from previous test
- ```
- """
-
- def reset_database(self):
- """
- Reset the database to a clean state.
- This method is called before and after each test in the class.
- """
- from beets_flask.database.setup import _reset_database
-
- _reset_database()
-
- @pytest.fixture(autouse=True, scope="class")
- def setup_database(self, testapp):
- """
- Automatically reset the database before and after ALL tests in this class.
-
- Args:
- db_session_factory: Pytest fixture providing a database session.
- """
- self.reset_database()
- yield
- self.reset_database()
-
-
-class IsolatedBeetsLibraryMixin(ABC):
- """
- A pytest mixin class to reset the beets library before and after ALL
- tests in this class are run.
-
- Usage:
- ```
- class TestMyFeature(IsolatedBeetsLibraryMixin):
- def test_something(self):
- # add to clean db
-
- def test_something_else(self):
- # db has data from previous test
- ```
- """
-
- @pytest.fixture(autouse=True, scope="class")
- def setup_beetslib(
- self,
- ):
- """Automatically reset the beets library before and after ALL tests in this class."""
- import beets.library
-
- from beets_flask.config.beets_config import refresh_config
-
- try:
- os.remove(os.environ["BEETSDIR"] + "/library.db")
- except OSError:
- pass
- lib = beets.library.Library(
- path=os.environ["BEETSDIR"] + "/library.db",
- directory=os.environ["BEETSDIR"] + "/imported",
- )
- config = refresh_config()
- config["directory"] = os.environ["BEETSDIR"] + "/imported"
- # Reset the beets library to a clean state
- yield
- print("Resetting beets library to a clean state...")
- # Reset the beets library to a clean state
- try:
- os.remove(os.environ["BEETSDIR"] + "/library.db")
- except OSError:
- pass
-
- @cached_property
- def beets_lib(self) -> BeetsLibrary:
- """Return the beets library instance."""
- import beets.library
-
- from beets_flask.config.beets_config import refresh_config
-
- lib = beets.library.Library(
- path=os.environ["BEETSDIR"] + "/library.db",
- directory=os.environ["BEETSDIR"] + "/imported",
- )
- refresh_config()
-
- # mock needed for the library to be available in the resources endpoints
- with mock.patch(
- "beets_flask.server.routes.library.resources.g",
- namedtuple("g", ["lib", "config"])(lib, None), # type: ignore[call-arg, arg-type]
- ):
- return lib
+from __future__ import annotations
+
+import os
+from abc import ABC
+from collections import namedtuple
+from functools import cached_property
+from typing import TYPE_CHECKING
+from unittest import mock
+
+import pytest
+
+if TYPE_CHECKING:
+ from beets_flask.importer.types import BeetsLibrary
+
+
+class IsolatedDBMixin(ABC):
+ """
+ A pytest mixin class to reset the database before and after ALL
+ tests in this class are run.
+
+ Usage:
+ ```
+ class TestMyFeature(IsolatedDBMixin):
+ def test_something(self):
+ # add to clean db
+
+ def test_something_else(self):
+ # db has data from previous test
+ ```
+ """
+
+ def reset_database(self):
+ """
+ Reset the database to a clean state.
+ This method is called before and after each test in the class.
+ """
+ from beets_flask.database.setup import _reset_database
+
+ _reset_database()
+
+ @pytest.fixture(autouse=True, scope="class")
+ def setup_database(self, testapp):
+ """
+ Automatically reset the database before and after ALL tests in this class.
+
+ Args:
+ db_session_factory: Pytest fixture providing a database session.
+ """
+ self.reset_database()
+ yield
+ self.reset_database()
+
+
+class IsolatedBeetsLibraryMixin(ABC):
+ """
+ A pytest mixin class to reset the beets library before and after ALL
+ tests in this class are run.
+
+ Usage:
+ ```
+ class TestMyFeature(IsolatedBeetsLibraryMixin):
+ def test_something(self):
+ # add to clean db
+
+ def test_something_else(self):
+ # db has data from previous test
+ ```
+ """
+
+ @pytest.fixture(autouse=True, scope="class")
+ def setup_beetslib(
+ self,
+ ):
+ """Automatically reset the beets library before and after ALL tests in this class."""
+ import beets.library
+
+ from beets_flask.config.beets_config import refresh_config
+
+ try:
+ os.remove(os.environ["BEETSDIR"] + "/library.db")
+ except OSError:
+ pass
+ lib = beets.library.Library(
+ path=os.environ["BEETSDIR"] + "/library.db",
+ directory=os.environ["BEETSDIR"] + "/imported",
+ )
+ config = refresh_config()
+ config["directory"] = os.environ["BEETSDIR"] + "/imported"
+ # Reset the beets library to a clean state
+ yield
+ print("Resetting beets library to a clean state...")
+ # Reset the beets library to a clean state
+ try:
+ os.remove(os.environ["BEETSDIR"] + "/library.db")
+ except OSError:
+ pass
+
+ @cached_property
+ def beets_lib(self) -> BeetsLibrary:
+ """Return the beets library instance."""
+ import beets.library
+
+ from beets_flask.config.beets_config import refresh_config
+
+ lib = beets.library.Library(
+ path=os.environ["BEETSDIR"] + "/library.db",
+ directory=os.environ["BEETSDIR"] + "/imported",
+ )
+ refresh_config()
+
+ # mock needed for the library to be available in the resources endpoints
+ with mock.patch(
+ "beets_flask.server.routes.library.resources.g",
+ namedtuple("g", ["lib", "config"])(lib, None), # type: ignore[call-arg, arg-type]
+ ):
+ return lib
diff --git a/backend/tests/mixins/plugins.py b/backend/tests/mixins/plugins.py
index 4a62ab46..849da8eb 100644
--- a/backend/tests/mixins/plugins.py
+++ b/backend/tests/mixins/plugins.py
@@ -1,39 +1,39 @@
-from abc import ABC
-from unittest import mock
-
-import pytest
-from beets.plugins import EventType, send
-
-
-class PluginEventsMixin(ABC):
- """
- Allows to test events sent by plugins.
- This mixin captures events sent by plugins during tests.
-
- Usage:
- ```
- class TestMyPlugin(PluginEventsMixin):
- def test_event(self):
- self.send_event("my_event", data="test")
- assert "my_event" in self.events
- ```
-
- """
-
- events: list[str] = []
-
- def send_event(self, event: EventType, **kwargs):
- self.events.append(event)
- return send(event, **kwargs)
-
- @pytest.fixture(autouse=True, scope="function")
- def mock_events(self):
- """Mock the emit_status decorator"""
-
- with mock.patch(
- "beets.plugins.send",
- self.send_event,
- ):
- yield
-
- self.events = []
+from abc import ABC
+from unittest import mock
+
+import pytest
+from beets.plugins import EventType, send
+
+
+class PluginEventsMixin(ABC):
+ """
+ Allows to test events sent by plugins.
+ This mixin captures events sent by plugins during tests.
+
+ Usage:
+ ```
+ class TestMyPlugin(PluginEventsMixin):
+ def test_event(self):
+ self.send_event("my_event", data="test")
+ assert "my_event" in self.events
+ ```
+
+ """
+
+ events: list[str] = []
+
+ def send_event(self, event: EventType, **kwargs):
+ self.events.append(event)
+ return send(event, **kwargs)
+
+ @pytest.fixture(autouse=True, scope="function")
+ def mock_events(self):
+ """Mock the emit_status decorator"""
+
+ with mock.patch(
+ "beets.plugins.send",
+ self.send_event,
+ ):
+ yield
+
+ self.events = []
diff --git a/backend/tests/unit/test_database/test_dates.py b/backend/tests/unit/test_database/test_dates.py
index c1931ca4..100a06f8 100644
--- a/backend/tests/unit/test_database/test_dates.py
+++ b/backend/tests/unit/test_database/test_dates.py
@@ -1,48 +1,48 @@
-import datetime
-from pathlib import Path
-
-import pytz
-
-from beets_flask.database.models import SessionStateInDb
-from beets_flask.importer.session import SessionState
-from tests.mixins.database import IsolatedDBMixin
-
-
-class TestDates(IsolatedDBMixin):
- """Test that dates are set correctly in the database for SessionStateInDb objects.
-
- This test checks that the created_at and updated_at fields are set to the current
- time in UTC when a SessionStateInDb object is created, and that they are
- deserialized correctly from the database.
- """
-
- def test_dates(self, db_session_factory, tmpdir_factory):
- # Create a new session state in db
- state = SessionState(Path(tmpdir_factory.mktemp("dates_test")))
-
- state_in_db = SessionStateInDb.from_live_state(state)
- with db_session_factory() as s:
- s.add(state_in_db)
- s.commit()
-
- # Check that the dates are set
- with db_session_factory() as s:
- state_in_db = s.query(SessionStateInDb).filter_by(id=state_in_db.id).one()
- assert state_in_db.created_at is not None
- assert state_in_db.updated_at is not None
-
- # Check that the dates are deserialized correctly
- assert isinstance(state_in_db.created_at, datetime.datetime)
- assert isinstance(state_in_db.updated_at, datetime.datetime)
-
- # Check that the timezone is UTC
- assert state_in_db.created_at.tzinfo is not None
- assert state_in_db.updated_at.tzinfo is not None
- assert state_in_db.created_at.tzinfo == pytz.UTC
- assert state_in_db.updated_at.tzinfo == pytz.UTC
-
- # Should be approximately equal to current local time
- now = datetime.datetime.now().astimezone()
-
- assert abs((state_in_db.created_at.astimezone() - now).total_seconds()) < 5
- assert abs((state_in_db.updated_at.astimezone() - now).total_seconds()) < 5
+import datetime
+from pathlib import Path
+
+import pytz
+
+from beets_flask.database.models import SessionStateInDb
+from beets_flask.importer.session import SessionState
+from tests.mixins.database import IsolatedDBMixin
+
+
+class TestDates(IsolatedDBMixin):
+ """Test that dates are set correctly in the database for SessionStateInDb objects.
+
+ This test checks that the created_at and updated_at fields are set to the current
+ time in UTC when a SessionStateInDb object is created, and that they are
+ deserialized correctly from the database.
+ """
+
+ def test_dates(self, db_session_factory, tmpdir_factory):
+ # Create a new session state in db
+ state = SessionState(Path(tmpdir_factory.mktemp("dates_test")))
+
+ state_in_db = SessionStateInDb.from_live_state(state)
+ with db_session_factory() as s:
+ s.add(state_in_db)
+ s.commit()
+
+ # Check that the dates are set
+ with db_session_factory() as s:
+ state_in_db = s.query(SessionStateInDb).filter_by(id=state_in_db.id).one()
+ assert state_in_db.created_at is not None
+ assert state_in_db.updated_at is not None
+
+ # Check that the dates are deserialized correctly
+ assert isinstance(state_in_db.created_at, datetime.datetime)
+ assert isinstance(state_in_db.updated_at, datetime.datetime)
+
+ # Check that the timezone is UTC
+ assert state_in_db.created_at.tzinfo is not None
+ assert state_in_db.updated_at.tzinfo is not None
+ assert state_in_db.created_at.tzinfo == pytz.UTC
+ assert state_in_db.updated_at.tzinfo == pytz.UTC
+
+ # Should be approximately equal to current local time
+ now = datetime.datetime.now().astimezone()
+
+ assert abs((state_in_db.created_at.astimezone() - now).total_seconds()) < 5
+ assert abs((state_in_db.updated_at.astimezone() - now).total_seconds()) < 5
diff --git a/backend/tests/unit/test_database/test_models_state.py b/backend/tests/unit/test_database/test_models_state.py
index 8d8b4765..05441f4e 100644
--- a/backend/tests/unit/test_database/test_models_state.py
+++ b/backend/tests/unit/test_database/test_models_state.py
@@ -1,76 +1,76 @@
-import logging
-from pathlib import Path
-
-import pytest
-from beets import autotag, importer
-
-from beets_flask.database.models.states import SessionStateInDb
-from beets_flask.importer.session import SessionState
-from beets_flask.importer.stages import Progress
-from tests.conftest import beets_lib_item
-from tests.unit.test_importer.test_states import get_album_match
-
-log = logging.getLogger(__name__)
-
-
-@pytest.fixture
-def import_task(beets_lib):
- item = beets_lib_item(title="title", path="path")
- task = importer.ImportTask(paths=[b"a path"], toppath=b"top path", items=[item])
-
- track_info = autotag.TrackInfo(title="match title")
- album_match = get_album_match(
- [track_info], [item], album="match album", data_url="url"
- )
-
- task.candidates = [album_match]
- return task
-
-
-class TestSessionStateInDb:
- state: SessionState
-
- @pytest.fixture(autouse=True)
- def gen_session_state(self, import_task, tmpdir_factory):
- state = SessionState(Path(tmpdir_factory.mktemp("beets_flask_disk")))
- state.upsert_task(import_task)
- self.state = state
-
- def test_from_session_state(
- self,
- ):
- state_in_db = SessionStateInDb.from_live_state(self.state)
-
- assert state_in_db.folder.full_path == str(self.state.path)
-
- # Tasks generated
- assert len(state_in_db.tasks) == 1
-
- # Candidates generated
- assert len(state_in_db.tasks[0].candidates) == 1
-
- def test_merge_session(self, db_session_factory):
- # Insert state in db
- state_in_db = SessionStateInDb.from_live_state(self.state)
- with db_session_factory() as s:
- s.add(state_in_db)
- s.commit()
-
- # At a later point we create a new state and merge it
- state_in_db = SessionStateInDb.from_live_state(self.state)
- with db_session_factory() as s:
- state_in_db.folder.full_path = "new path"
- state_in_db.tasks[0].progress = Progress.IMPORT_COMPLETED
- state_in_db.tasks[0].candidates = []
- s.autoflush = True
- s.merge(state_in_db)
- s.commit()
-
- with db_session_factory() as s:
- # Check that new path is in db
- state_in_db = s.query(SessionStateInDb).filter_by(id=state_in_db.id).one()
- assert len(state_in_db.tasks) == 1
-
- # Check that the progress is updated
- assert state_in_db.tasks[0].progress == Progress.IMPORT_COMPLETED
- assert len(state_in_db.tasks[0].candidates) == 0
+import logging
+from pathlib import Path
+
+import pytest
+from beets import autotag, importer
+
+from beets_flask.database.models.states import SessionStateInDb
+from beets_flask.importer.session import SessionState
+from beets_flask.importer.stages import Progress
+from tests.conftest import beets_lib_item
+from tests.unit.test_importer.test_states import get_album_match
+
+log = logging.getLogger(__name__)
+
+
+@pytest.fixture
+def import_task(beets_lib):
+ item = beets_lib_item(title="title", path="path")
+ task = importer.ImportTask(paths=[b"a path"], toppath=b"top path", items=[item])
+
+ track_info = autotag.TrackInfo(title="match title")
+ album_match = get_album_match(
+ [track_info], [item], album="match album", data_url="url"
+ )
+
+ task.candidates = [album_match]
+ return task
+
+
+class TestSessionStateInDb:
+ state: SessionState
+
+ @pytest.fixture(autouse=True)
+ def gen_session_state(self, import_task, tmpdir_factory):
+ state = SessionState(Path(tmpdir_factory.mktemp("beets_flask_disk")))
+ state.upsert_task(import_task)
+ self.state = state
+
+ def test_from_session_state(
+ self,
+ ):
+ state_in_db = SessionStateInDb.from_live_state(self.state)
+
+ assert state_in_db.folder.full_path == str(self.state.path)
+
+ # Tasks generated
+ assert len(state_in_db.tasks) == 1
+
+ # Candidates generated
+ assert len(state_in_db.tasks[0].candidates) == 1
+
+ def test_merge_session(self, db_session_factory):
+ # Insert state in db
+ state_in_db = SessionStateInDb.from_live_state(self.state)
+ with db_session_factory() as s:
+ s.add(state_in_db)
+ s.commit()
+
+ # At a later point we create a new state and merge it
+ state_in_db = SessionStateInDb.from_live_state(self.state)
+ with db_session_factory() as s:
+ state_in_db.folder.full_path = "new path"
+ state_in_db.tasks[0].progress = Progress.IMPORT_COMPLETED
+ state_in_db.tasks[0].candidates = []
+ s.autoflush = True
+ s.merge(state_in_db)
+ s.commit()
+
+ with db_session_factory() as s:
+ # Check that new path is in db
+ state_in_db = s.query(SessionStateInDb).filter_by(id=state_in_db.id).one()
+ assert len(state_in_db.tasks) == 1
+
+ # Check that the progress is updated
+ assert state_in_db.tasks[0].progress == Progress.IMPORT_COMPLETED
+ assert len(state_in_db.tasks[0].candidates) == 0
diff --git a/backend/tests/unit/test_database/test_setup.py b/backend/tests/unit/test_database/test_setup.py
index c887f8e3..37ca5db2 100644
--- a/backend/tests/unit/test_database/test_setup.py
+++ b/backend/tests/unit/test_database/test_setup.py
@@ -1,43 +1,43 @@
-from collections.abc import Callable
-from contextlib import _GeneratorContextManager
-
-from sqlalchemy import select
-from sqlalchemy.orm import Session
-
-from beets_flask.database.models.states import FolderInDb
-from beets_flask.database.setup import _reset_database, with_db_session
-
-
-def test_with_db_session_decorator(testapp):
- # Needs the testapp
-
- @with_db_session
- def sample_function(session=None):
- return session is not None
-
- assert sample_function() is True
-
-
-def test_reset(
- db_session_factory: Callable[..., _GeneratorContextManager[Session, None, None]],
-):
- """Test if the database is reset correctly after calling _reset_database."""
-
- # Write something to db
- with db_session_factory() as db_session:
- f = FolderInDb(path="/test", hash="test", is_album=False)
- db_session.add(f)
- db_session.commit()
-
- # Check if it exists
- with db_session_factory() as db_session:
- query = select(FolderInDb).where(FolderInDb.full_path == "/test")
- assert db_session.execute(query).scalar_one_or_none() is not None
-
- # Reset the database
- _reset_database()
-
- # Check if it is empty
- with db_session_factory() as db_session:
- query = select(FolderInDb).where(FolderInDb.full_path == "/test")
- assert db_session.execute(query).scalar_one_or_none() is None
+from collections.abc import Callable
+from contextlib import _GeneratorContextManager
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from beets_flask.database.models.states import FolderInDb
+from beets_flask.database.setup import _reset_database, with_db_session
+
+
+def test_with_db_session_decorator(testapp):
+ # Needs the testapp
+
+ @with_db_session
+ def sample_function(session=None):
+ return session is not None
+
+ assert sample_function() is True
+
+
+def test_reset(
+ db_session_factory: Callable[..., _GeneratorContextManager[Session, None, None]],
+):
+ """Test if the database is reset correctly after calling _reset_database."""
+
+ # Write something to db
+ with db_session_factory() as db_session:
+ f = FolderInDb(path="/test", hash="test", is_album=False)
+ db_session.add(f)
+ db_session.commit()
+
+ # Check if it exists
+ with db_session_factory() as db_session:
+ query = select(FolderInDb).where(FolderInDb.full_path == "/test")
+ assert db_session.execute(query).scalar_one_or_none() is not None
+
+ # Reset the database
+ _reset_database()
+
+ # Check if it is empty
+ with db_session_factory() as db_session:
+ query = select(FolderInDb).where(FolderInDb.full_path == "/test")
+ assert db_session.execute(query).scalar_one_or_none() is None
diff --git a/backend/tests/unit/test_disk.py b/backend/tests/unit/test_disk.py
index 75586add..489479c3 100644
--- a/backend/tests/unit/test_disk.py
+++ b/backend/tests/unit/test_disk.py
@@ -1,457 +1,457 @@
-import os
-import shutil
-import zipfile
-from pathlib import Path
-from unittest import mock
-
-import pytest
-from cachetools import Cache
-from confuse import AttrDict
-
-from beets_flask.dirhash_custom import dirhash_c
-from beets_flask.disk import Folder, _matches_patterns, audio_regex
-
-
-def touch(path):
- with open(path, "w") as f:
- pass
-
-
-@pytest.fixture(scope="session")
-def base(tmpdir_factory):
- """
- Create a folder structure for testing purposes.
- """
- base = str(tmpdir_factory.mktemp("beets_flask_disk"))
-
- # music files and misc
- os.makedirs(os.path.join(base, "artist/album_good"))
- source = Path(__file__).parent.parent / "data" / "audio"
- dest = Path(base) / "artist" / "album_good"
- shutil.copytree(source, dest, dirs_exist_ok=True)
-
- # remove multi folder
- shutil.rmtree(
- dest / "multi",
- )
-
- # empty folder
- os.makedirs(os.path.join(base, "artist/album_empty"))
-
- # no music files, just junk.
- # this depends on the ignored filetypes in the beets config.
- # but with the default config, hidden files should be ignored:
- os.makedirs(os.path.join(base, "artist/album_junk"))
- touch(os.path.join(base, "artist/album_junk/.junk.jpg"))
-
- # nested folders
- os.makedirs(os.path.join(base, "artist/album_multi/1/CD1"))
- os.makedirs(os.path.join(base, "artist/album_multi/1/CD2"))
- touch(os.path.join(base, "artist/album_multi/1/CD1/track_1.mp3"))
- touch(os.path.join(base, "artist/album_multi/1/CD2/track_1.mp3"))
-
- os.makedirs(os.path.join(base, "artist/album_multi/2/CD1"))
- os.makedirs(os.path.join(base, "artist/album_multi/2/CD2"))
- touch(os.path.join(base, "artist/album_multi/2/CD1/track_1.mp3"))
- touch(os.path.join(base, "artist/album_multi/2/CD2/track_1.mp3"))
- # the annoying rogue element
- touch(os.path.join(base, "artist/album_multi/2/should_not_be_here.mp3"))
-
- yield base
-
- shutil.rmtree(base)
-
-
-@pytest.fixture(scope="session")
-def s_base(tmpdir_factory):
- """
- Create a folder structure for testing purposes.
- No real files, just dummys
- """
-
- base = str(tmpdir_factory.mktemp("beets_flask_structure"))
-
- # We want to test if is_album_folder works correctly with the different structures.
-
- # empty folder
- os.makedirs(os.path.join(base, "artist/album_empty"))
-
- # nested folders
- os.makedirs(os.path.join(base, "artist/album_multi/CD1"))
- os.makedirs(os.path.join(base, "artist/album_multi/CD2"))
- touch(os.path.join(base, "artist/album_multi/CD1/track_1.mp3"))
- touch(os.path.join(base, "artist/album_multi/CD2/track_1.mp3"))
-
- # no music files, just junk.
- # this depends on the ignored filetypes in the beets config.
- # but with the default config, hidden files should be ignored:
- os.makedirs(os.path.join(base, "artist/album_junk"))
- touch(os.path.join(base, "artist/album_junk/.junk.jpg"))
-
- # the annoying rogue element (same as nested folders, but with a rogue file)
- os.makedirs(os.path.join(base, "artist/album_rogue/CD1"))
- os.makedirs(os.path.join(base, "artist/album_rogue/CD2"))
- touch(os.path.join(base, "artist/album_rogue/CD1/track_1.mp3"))
- touch(os.path.join(base, "artist/album_rogue/CD2/track_1.mp3"))
- touch(os.path.join(base, "artist/album_rogue/should_not_be_here.mp3"))
-
- # Good
- os.makedirs(os.path.join(base, "artist/album_good"))
- touch(os.path.join(base, "artist/album_good/track_1.mp3"))
- touch(os.path.join(base, "artist/album_good/track_2.mp3"))
-
- # Archive, needs to be an actual zip file, just touching is not enough for the beets' internal archive detection
- os.makedirs(os.path.join(base, "artist/archive"))
- with zipfile.ZipFile(os.path.join(base, "artist/archive/foo.zip"), "w") as _zipf:
- pass
-
- # Dir with archive and music files
- os.makedirs(os.path.join(base, "artist/archive_and_music"))
- touch(os.path.join(base, "artist/archive_and_music/track_1.mp3"))
- with zipfile.ZipFile(
- os.path.join(base, "artist/archive_and_music/foo.zip"), "w"
- ) as _zipf:
- pass
-
- yield base
-
- shutil.rmtree(base)
-
-
-from beets_flask.disk import is_album_folder
-
-
-class TestIsAlbumFolder:
- @pytest.mark.parametrize(
- "type",
- [Path, str],
- )
- def test_folder_empty(self, type, s_base):
- p = type(s_base + "/artist/album_empty")
- assert not is_album_folder(p)
-
- @pytest.mark.parametrize(
- "type",
- [Path, str],
- )
- def test_folder_good(self, type, s_base):
- p = type(s_base + "/artist/album_good")
- assert is_album_folder(p)
-
- def test_folder_junk(self, s_base):
- p = s_base + "/artist/album_junk"
- assert not is_album_folder(p)
-
- def test_folder_multi(self, s_base):
- assert is_album_folder(s_base + "/artist/album_multi")
- assert is_album_folder(s_base + "/artist/album_multi/CD1")
- assert is_album_folder(s_base + "/artist/album_multi/CD2")
-
- def test_folder_rogue(self, s_base):
- assert is_album_folder(s_base + "/artist/album_rogue")
- assert is_album_folder(s_base + "/artist/album_rogue/CD1")
- assert is_album_folder(s_base + "/artist/album_rogue/CD2")
-
- def test_archive(self, s_base):
- assert is_album_folder(s_base + "/artist/archive") == False
- assert is_album_folder(s_base + "/artist/archive/foo.zip")
-
- @pytest.mark.skip("is_album_folder tricky logic for archive and music")
- # but this is desired behaviour. revisit when consolidating
- # `is_album_folder` and `all_album_folders`
- def test_archive_and_music(self, s_base):
- assert is_album_folder(s_base + "/artist/archive_and_music")
- assert is_album_folder(s_base + "/artist/archive_and_music/foo.zip") == False
-
-
-class TestAllAlbumFolders:
- @pytest.mark.parametrize(
- "type",
- [Path, str],
- )
- def test_no_subdirs(self, type, s_base):
- from beets_flask.disk import all_album_folders
-
- all_albums = [
- type(s_base + "/artist/album_good"),
- type(s_base + "/artist/album_multi"),
- # Rogue is detected as an album folder because of rogue file
- type(s_base + "/artist/album_rogue"),
- type(s_base + "/artist/album_rogue/CD1"),
- type(s_base + "/artist/album_rogue/CD2"),
- # Archive is detected as an album folder
- type(s_base + "/artist/archive/foo.zip"),
- # Archive and music is detected as an album folder
- # the archive inside is not
- type(s_base + "/artist/archive_and_music"),
- ]
-
- found_folders = all_album_folders(s_base)
- expected_folders = [Path(p) for p in all_albums]
-
- assert set(found_folders) == set(expected_folders)
-
- @pytest.mark.parametrize(
- "type",
- [Path, str],
- )
- def test_with_subdirs(self, type, s_base):
- from beets_flask.disk import all_album_folders
-
- all_albums_with_subdirs = [
- type(s_base + "/artist/album_good"),
- type(s_base + "/artist/album_multi"),
- type(s_base + "/artist/album_multi/CD1"),
- type(s_base + "/artist/album_multi/CD2"),
- type(s_base + "/artist/album_rogue"),
- type(s_base + "/artist/album_rogue/CD1"),
- type(s_base + "/artist/album_rogue/CD2"),
- # archives count as directories (album folders).
- type(s_base + "/artist/archive/foo.zip"),
- # Archive and music is detected as an album folder
- type(s_base + "/artist/archive_and_music"),
- ]
-
- found_folders = all_album_folders(s_base, subdirs=True)
- expected_folders = [Path(p) for p in all_albums_with_subdirs]
-
- assert set(found_folders) == set(expected_folders)
-
- # All zips in folder -> parent not album
- # One zip + other filers -> parent is album
-
-
-class TestIsWithinMultiDir:
- @pytest.mark.parametrize(
- "type",
- [Path, str],
- )
- def test_is_within_multi_dir(self, type, s_base):
- from beets_flask.disk import is_within_multi_dir
-
- assert is_within_multi_dir(type(s_base + "/artist/album_multi/CD1"))
- assert is_within_multi_dir(type(s_base + "/artist/album_multi/CD2"))
- # should work with and without trailing slashes
- assert is_within_multi_dir(type(s_base + "/artist/album_rogue/CD1"))
- assert is_within_multi_dir(type(s_base + "/artist/album_rogue/CD2"))
- assert not is_within_multi_dir(type(s_base + "/artist/album_multi/"))
- assert not is_within_multi_dir(type(s_base + "/artist/album_good/"))
- assert not is_within_multi_dir(type(s_base + "/artist/archive/foo.zip"))
-
-
-# input, use_parent_for_multidisc, expected
-testdata = [
- (
- ["/artist/album_multi/1/CD1/track_1.mp3"],
- True,
- ["/artist/album_multi/1"],
- ),
- (
- ["/artist/album_multi/1/CD1/track_1.mp3"],
- False,
- ["/artist/album_multi/1/CD1"],
- ),
- (
- [
- "/artist/album_multi/1/CD1/track_1.mp3",
- "/artist/album_multi/1/CD2/track_1.mp3",
- ],
- True,
- ["/artist/album_multi/1"],
- ),
- (
- [
- "/artist/album_multi/1/CD1/track_1.mp3",
- "/artist/album_multi/1/CD2/track_1.mp3",
- ],
- False,
- [
- "/artist/album_multi/1/CD1",
- "/artist/album_multi/1/CD2",
- ],
- ),
-]
-
-
-@pytest.mark.parametrize(
- "input, use_parent_for_multidisc, expected",
- testdata,
-)
-def test_album_folders_from_track(
- base, input: list[str], use_parent_for_multidisc: bool, expected: list[str]
-):
- from beets_flask.disk import album_folders_from_track_paths
-
- # Try legacy using string paths
- # TODO: Remove once we have migrated to Path objects
- folders = album_folders_from_track_paths(
- [base + p for p in input],
- use_parent_for_multidisc=use_parent_for_multidisc,
- )
-
- assert len(folders) == len(expected)
- for i, e in enumerate(expected):
- assert folders[i] == Path(base + e)
-
- # Test same thing with Path objects
- folders = album_folders_from_track_paths(
- [Path(base + p) for p in input],
- use_parent_for_multidisc=use_parent_for_multidisc,
- )
-
- assert len(folders) == len(expected)
- for i, e in enumerate(expected):
- assert folders[i] == Path(base + e)
-
-
-cache_options: list[Cache | None] = [None, Cache(maxsize=2**16)]
-
-
-@pytest.mark.parametrize(
- "cache",
- cache_options,
-)
-def test_dirhash(tmpdir_factory: pytest.TempdirFactory, cache: Cache | None):
- base = Path(tmpdir_factory.mktemp("dirhash_sample"))
-
- # adding a file directly inside
- hash_0 = dirhash_c(base, cache)
- (base / "dummy.txt").touch()
- assert dirhash_c(base, cache) != hash_0
-
- # when focusing on audio files, adding a txt should not change hash
- assert dirhash_c(base, cache, filter_regex=audio_regex) == hash_0
-
- (base / "dummy.mp3").touch()
- assert dirhash_c(base, cache, filter_regex=audio_regex) != hash_0
-
- # deletion back to old hash
- os.remove(base / "dummy.txt")
- os.remove(base / "dummy.mp3")
- assert dirhash_c(base, cache) == hash_0
-
- # creating subdirectories or moving them should change the hash
- os.makedirs(base / "subdir")
- hash_1 = dirhash_c(base, cache)
- assert hash_1 != hash_0
-
- os.rename(base / "subdir", base / "subdir2")
- assert dirhash_c(base, cache) != hash_1
-
-
-@pytest.fixture
-def tmp_dir_with_ignored_items(tmpdir):
- """Fixture to create a temporary directory with ignored files and folders."""
- base = Path(tmpdir)
-
- # Create ignored files and folders
- ignored_files = [
- ".hidden_file.txt",
- "temp_file.tmp",
- "backup~",
- ]
- ignored_dirs = [
- ".hidden_dir",
- "temp_dir",
- ]
-
- for file in ignored_files:
- (base / file).touch()
-
- for dir_name in ignored_dirs:
- (base / dir_name).mkdir()
-
- # Create non-ignored files and folders
- (base / "visible_file.txt").touch()
- (base / "visible_dir").mkdir()
-
- return base
-
-
-def test_matches_patterns():
- """Test that _matches_patterns correctly identifies ignored patterns."""
- patterns = ["*.tmp", ".*", "*~", "temp_*"]
-
- # Files that should be ignored
- assert _matches_patterns("temp_file.tmp", patterns)
- assert _matches_patterns(".hidden", patterns)
- assert _matches_patterns("backup~", patterns)
- assert _matches_patterns("temp_dir", patterns)
-
- # Files that should not be ignored
- assert not _matches_patterns("visible.txt", patterns)
- assert not _matches_patterns("normal_file", patterns)
- assert not _matches_patterns("music.mp3", patterns)
-
-
-def test_from_path_ignores_files_and_folders(tmp_dir_with_ignored_items):
- """Test that Folder.from_path ignores files and folders based on patterns."""
- # Mock the config to return specific ignore patterns
- ignore_patterns = ["*.tmp", ".*", "*~", "temp_*"]
-
- # Patch the get_config function to return our test ignore patterns
- with mock.patch("beets_flask.disk.get_config") as mock_get_config:
- mock_get_config.return_value = AttrDict(
- {
- "ignore_globs": ignore_patterns,
- }
- )
-
- folder = Folder.from_path(tmp_dir_with_ignored_items, subdirs=False)
-
- # Check that ignored files are not present
- ignored_files = [".hidden_file.txt", "temp_file.tmp", "backup~"]
- for file in ignored_files:
- assert not any(os.path.basename(c.full_path) == file for c in folder.walk())
-
- # Check that ignored directories are not present
- ignored_dirs = [".hidden_dir", "temp_dir"]
- for dir_name in ignored_dirs:
- assert not any(
- os.path.basename(c.full_path) == dir_name for c in folder.walk()
- )
-
- # Check that non-ignored items are present
- assert any(
- os.path.basename(c.full_path) == "visible_file.txt" for c in folder.walk()
- )
- assert any(
- os.path.basename(c.full_path) == "visible_dir" for c in folder.walk()
- )
-
-
-def test_from_path_ignores_nested_items(tmpdir):
- """Test that Folder.from_path ignores nested files and folders."""
- base = Path(tmpdir)
-
- # Create a nested structure with ignored items
- (base / "visible_dir").mkdir()
- (base / "visible_dir" / ".hidden_nested").mkdir()
- (base / "visible_dir" / ".hidden_nested" / "file.txt").touch()
- (base / "visible_dir" / "temp_file.tmp").touch()
- (base / "visible_dir" / "normal_file.txt").touch()
-
- # Mock the config to return specific ignore patterns
- ignore_patterns = ["*.tmp", ".*"]
-
- with mock.patch("beets_flask.disk.get_config") as mock_get_config:
- mock_get_config.return_value = AttrDict(
- {
- "ignore_globs": ignore_patterns,
- }
- )
-
- folder = Folder.from_path(base, subdirs=True)
-
- # Check that ignored nested items are not present
- assert not any(
- os.path.basename(c.full_path) == ".hidden_nested" for c in folder.walk()
- )
- assert not any(
- os.path.basename(c.full_path) == "temp_file.tmp" for c in folder.walk()
- )
-
- # Check that non-ignored nested items are present
- assert any(
- os.path.basename(c.full_path) == "normal_file.txt" for c in folder.walk()
- )
+import os
+import shutil
+import zipfile
+from pathlib import Path
+from unittest import mock
+
+import pytest
+from cachetools import Cache
+from confuse import AttrDict
+
+from beets_flask.dirhash_custom import dirhash_c
+from beets_flask.disk import Folder, _matches_patterns, audio_regex
+
+
+def touch(path):
+ with open(path, "w") as f:
+ pass
+
+
+@pytest.fixture(scope="session")
+def base(tmpdir_factory):
+ """
+ Create a folder structure for testing purposes.
+ """
+ base = str(tmpdir_factory.mktemp("beets_flask_disk"))
+
+ # music files and misc
+ os.makedirs(os.path.join(base, "artist/album_good"))
+ source = Path(__file__).parent.parent / "data" / "audio"
+ dest = Path(base) / "artist" / "album_good"
+ shutil.copytree(source, dest, dirs_exist_ok=True)
+
+ # remove multi folder
+ shutil.rmtree(
+ dest / "multi",
+ )
+
+ # empty folder
+ os.makedirs(os.path.join(base, "artist/album_empty"))
+
+ # no music files, just junk.
+ # this depends on the ignored filetypes in the beets config.
+ # but with the default config, hidden files should be ignored:
+ os.makedirs(os.path.join(base, "artist/album_junk"))
+ touch(os.path.join(base, "artist/album_junk/.junk.jpg"))
+
+ # nested folders
+ os.makedirs(os.path.join(base, "artist/album_multi/1/CD1"))
+ os.makedirs(os.path.join(base, "artist/album_multi/1/CD2"))
+ touch(os.path.join(base, "artist/album_multi/1/CD1/track_1.mp3"))
+ touch(os.path.join(base, "artist/album_multi/1/CD2/track_1.mp3"))
+
+ os.makedirs(os.path.join(base, "artist/album_multi/2/CD1"))
+ os.makedirs(os.path.join(base, "artist/album_multi/2/CD2"))
+ touch(os.path.join(base, "artist/album_multi/2/CD1/track_1.mp3"))
+ touch(os.path.join(base, "artist/album_multi/2/CD2/track_1.mp3"))
+ # the annoying rogue element
+ touch(os.path.join(base, "artist/album_multi/2/should_not_be_here.mp3"))
+
+ yield base
+
+ shutil.rmtree(base)
+
+
+@pytest.fixture(scope="session")
+def s_base(tmpdir_factory):
+ """
+ Create a folder structure for testing purposes.
+ No real files, just dummys
+ """
+
+ base = str(tmpdir_factory.mktemp("beets_flask_structure"))
+
+ # We want to test if is_album_folder works correctly with the different structures.
+
+ # empty folder
+ os.makedirs(os.path.join(base, "artist/album_empty"))
+
+ # nested folders
+ os.makedirs(os.path.join(base, "artist/album_multi/CD1"))
+ os.makedirs(os.path.join(base, "artist/album_multi/CD2"))
+ touch(os.path.join(base, "artist/album_multi/CD1/track_1.mp3"))
+ touch(os.path.join(base, "artist/album_multi/CD2/track_1.mp3"))
+
+ # no music files, just junk.
+ # this depends on the ignored filetypes in the beets config.
+ # but with the default config, hidden files should be ignored:
+ os.makedirs(os.path.join(base, "artist/album_junk"))
+ touch(os.path.join(base, "artist/album_junk/.junk.jpg"))
+
+ # the annoying rogue element (same as nested folders, but with a rogue file)
+ os.makedirs(os.path.join(base, "artist/album_rogue/CD1"))
+ os.makedirs(os.path.join(base, "artist/album_rogue/CD2"))
+ touch(os.path.join(base, "artist/album_rogue/CD1/track_1.mp3"))
+ touch(os.path.join(base, "artist/album_rogue/CD2/track_1.mp3"))
+ touch(os.path.join(base, "artist/album_rogue/should_not_be_here.mp3"))
+
+ # Good
+ os.makedirs(os.path.join(base, "artist/album_good"))
+ touch(os.path.join(base, "artist/album_good/track_1.mp3"))
+ touch(os.path.join(base, "artist/album_good/track_2.mp3"))
+
+ # Archive, needs to be an actual zip file, just touching is not enough for the beets' internal archive detection
+ os.makedirs(os.path.join(base, "artist/archive"))
+ with zipfile.ZipFile(os.path.join(base, "artist/archive/foo.zip"), "w") as _zipf:
+ pass
+
+ # Dir with archive and music files
+ os.makedirs(os.path.join(base, "artist/archive_and_music"))
+ touch(os.path.join(base, "artist/archive_and_music/track_1.mp3"))
+ with zipfile.ZipFile(
+ os.path.join(base, "artist/archive_and_music/foo.zip"), "w"
+ ) as _zipf:
+ pass
+
+ yield base
+
+ shutil.rmtree(base)
+
+
+from beets_flask.disk import is_album_folder
+
+
+class TestIsAlbumFolder:
+ @pytest.mark.parametrize(
+ "type",
+ [Path, str],
+ )
+ def test_folder_empty(self, type, s_base):
+ p = type(s_base + "/artist/album_empty")
+ assert not is_album_folder(p)
+
+ @pytest.mark.parametrize(
+ "type",
+ [Path, str],
+ )
+ def test_folder_good(self, type, s_base):
+ p = type(s_base + "/artist/album_good")
+ assert is_album_folder(p)
+
+ def test_folder_junk(self, s_base):
+ p = s_base + "/artist/album_junk"
+ assert not is_album_folder(p)
+
+ def test_folder_multi(self, s_base):
+ assert is_album_folder(s_base + "/artist/album_multi")
+ assert is_album_folder(s_base + "/artist/album_multi/CD1")
+ assert is_album_folder(s_base + "/artist/album_multi/CD2")
+
+ def test_folder_rogue(self, s_base):
+ assert is_album_folder(s_base + "/artist/album_rogue")
+ assert is_album_folder(s_base + "/artist/album_rogue/CD1")
+ assert is_album_folder(s_base + "/artist/album_rogue/CD2")
+
+ def test_archive(self, s_base):
+ assert is_album_folder(s_base + "/artist/archive") == False
+ assert is_album_folder(s_base + "/artist/archive/foo.zip")
+
+ @pytest.mark.skip("is_album_folder tricky logic for archive and music")
+ # but this is desired behaviour. revisit when consolidating
+ # `is_album_folder` and `all_album_folders`
+ def test_archive_and_music(self, s_base):
+ assert is_album_folder(s_base + "/artist/archive_and_music")
+ assert is_album_folder(s_base + "/artist/archive_and_music/foo.zip") == False
+
+
+class TestAllAlbumFolders:
+ @pytest.mark.parametrize(
+ "type",
+ [Path, str],
+ )
+ def test_no_subdirs(self, type, s_base):
+ from beets_flask.disk import all_album_folders
+
+ all_albums = [
+ type(s_base + "/artist/album_good"),
+ type(s_base + "/artist/album_multi"),
+ # Rogue is detected as an album folder because of rogue file
+ type(s_base + "/artist/album_rogue"),
+ type(s_base + "/artist/album_rogue/CD1"),
+ type(s_base + "/artist/album_rogue/CD2"),
+ # Archive is detected as an album folder
+ type(s_base + "/artist/archive/foo.zip"),
+ # Archive and music is detected as an album folder
+ # the archive inside is not
+ type(s_base + "/artist/archive_and_music"),
+ ]
+
+ found_folders = all_album_folders(s_base)
+ expected_folders = [Path(p) for p in all_albums]
+
+ assert set(found_folders) == set(expected_folders)
+
+ @pytest.mark.parametrize(
+ "type",
+ [Path, str],
+ )
+ def test_with_subdirs(self, type, s_base):
+ from beets_flask.disk import all_album_folders
+
+ all_albums_with_subdirs = [
+ type(s_base + "/artist/album_good"),
+ type(s_base + "/artist/album_multi"),
+ type(s_base + "/artist/album_multi/CD1"),
+ type(s_base + "/artist/album_multi/CD2"),
+ type(s_base + "/artist/album_rogue"),
+ type(s_base + "/artist/album_rogue/CD1"),
+ type(s_base + "/artist/album_rogue/CD2"),
+ # archives count as directories (album folders).
+ type(s_base + "/artist/archive/foo.zip"),
+ # Archive and music is detected as an album folder
+ type(s_base + "/artist/archive_and_music"),
+ ]
+
+ found_folders = all_album_folders(s_base, subdirs=True)
+ expected_folders = [Path(p) for p in all_albums_with_subdirs]
+
+ assert set(found_folders) == set(expected_folders)
+
+ # All zips in folder -> parent not album
+ # One zip + other filers -> parent is album
+
+
+class TestIsWithinMultiDir:
+ @pytest.mark.parametrize(
+ "type",
+ [Path, str],
+ )
+ def test_is_within_multi_dir(self, type, s_base):
+ from beets_flask.disk import is_within_multi_dir
+
+ assert is_within_multi_dir(type(s_base + "/artist/album_multi/CD1"))
+ assert is_within_multi_dir(type(s_base + "/artist/album_multi/CD2"))
+ # should work with and without trailing slashes
+ assert is_within_multi_dir(type(s_base + "/artist/album_rogue/CD1"))
+ assert is_within_multi_dir(type(s_base + "/artist/album_rogue/CD2"))
+ assert not is_within_multi_dir(type(s_base + "/artist/album_multi/"))
+ assert not is_within_multi_dir(type(s_base + "/artist/album_good/"))
+ assert not is_within_multi_dir(type(s_base + "/artist/archive/foo.zip"))
+
+
+# input, use_parent_for_multidisc, expected
+testdata = [
+ (
+ ["/artist/album_multi/1/CD1/track_1.mp3"],
+ True,
+ ["/artist/album_multi/1"],
+ ),
+ (
+ ["/artist/album_multi/1/CD1/track_1.mp3"],
+ False,
+ ["/artist/album_multi/1/CD1"],
+ ),
+ (
+ [
+ "/artist/album_multi/1/CD1/track_1.mp3",
+ "/artist/album_multi/1/CD2/track_1.mp3",
+ ],
+ True,
+ ["/artist/album_multi/1"],
+ ),
+ (
+ [
+ "/artist/album_multi/1/CD1/track_1.mp3",
+ "/artist/album_multi/1/CD2/track_1.mp3",
+ ],
+ False,
+ [
+ "/artist/album_multi/1/CD1",
+ "/artist/album_multi/1/CD2",
+ ],
+ ),
+]
+
+
+@pytest.mark.parametrize(
+ "input, use_parent_for_multidisc, expected",
+ testdata,
+)
+def test_album_folders_from_track(
+ base, input: list[str], use_parent_for_multidisc: bool, expected: list[str]
+):
+ from beets_flask.disk import album_folders_from_track_paths
+
+ # Try legacy using string paths
+ # TODO: Remove once we have migrated to Path objects
+ folders = album_folders_from_track_paths(
+ [base + p for p in input],
+ use_parent_for_multidisc=use_parent_for_multidisc,
+ )
+
+ assert len(folders) == len(expected)
+ for i, e in enumerate(expected):
+ assert folders[i] == Path(base + e)
+
+ # Test same thing with Path objects
+ folders = album_folders_from_track_paths(
+ [Path(base + p) for p in input],
+ use_parent_for_multidisc=use_parent_for_multidisc,
+ )
+
+ assert len(folders) == len(expected)
+ for i, e in enumerate(expected):
+ assert folders[i] == Path(base + e)
+
+
+cache_options: list[Cache | None] = [None, Cache(maxsize=2**16)]
+
+
+@pytest.mark.parametrize(
+ "cache",
+ cache_options,
+)
+def test_dirhash(tmpdir_factory: pytest.TempdirFactory, cache: Cache | None):
+ base = Path(tmpdir_factory.mktemp("dirhash_sample"))
+
+ # adding a file directly inside
+ hash_0 = dirhash_c(base, cache)
+ (base / "dummy.txt").touch()
+ assert dirhash_c(base, cache) != hash_0
+
+ # when focusing on audio files, adding a txt should not change hash
+ assert dirhash_c(base, cache, filter_regex=audio_regex) == hash_0
+
+ (base / "dummy.mp3").touch()
+ assert dirhash_c(base, cache, filter_regex=audio_regex) != hash_0
+
+ # deletion back to old hash
+ os.remove(base / "dummy.txt")
+ os.remove(base / "dummy.mp3")
+ assert dirhash_c(base, cache) == hash_0
+
+ # creating subdirectories or moving them should change the hash
+ os.makedirs(base / "subdir")
+ hash_1 = dirhash_c(base, cache)
+ assert hash_1 != hash_0
+
+ os.rename(base / "subdir", base / "subdir2")
+ assert dirhash_c(base, cache) != hash_1
+
+
+@pytest.fixture
+def tmp_dir_with_ignored_items(tmpdir):
+ """Fixture to create a temporary directory with ignored files and folders."""
+ base = Path(tmpdir)
+
+ # Create ignored files and folders
+ ignored_files = [
+ ".hidden_file.txt",
+ "temp_file.tmp",
+ "backup~",
+ ]
+ ignored_dirs = [
+ ".hidden_dir",
+ "temp_dir",
+ ]
+
+ for file in ignored_files:
+ (base / file).touch()
+
+ for dir_name in ignored_dirs:
+ (base / dir_name).mkdir()
+
+ # Create non-ignored files and folders
+ (base / "visible_file.txt").touch()
+ (base / "visible_dir").mkdir()
+
+ return base
+
+
+def test_matches_patterns():
+ """Test that _matches_patterns correctly identifies ignored patterns."""
+ patterns = ["*.tmp", ".*", "*~", "temp_*"]
+
+ # Files that should be ignored
+ assert _matches_patterns("temp_file.tmp", patterns)
+ assert _matches_patterns(".hidden", patterns)
+ assert _matches_patterns("backup~", patterns)
+ assert _matches_patterns("temp_dir", patterns)
+
+ # Files that should not be ignored
+ assert not _matches_patterns("visible.txt", patterns)
+ assert not _matches_patterns("normal_file", patterns)
+ assert not _matches_patterns("music.mp3", patterns)
+
+
+def test_from_path_ignores_files_and_folders(tmp_dir_with_ignored_items):
+ """Test that Folder.from_path ignores files and folders based on patterns."""
+ # Mock the config to return specific ignore patterns
+ ignore_patterns = ["*.tmp", ".*", "*~", "temp_*"]
+
+ # Patch the get_config function to return our test ignore patterns
+ with mock.patch("beets_flask.disk.get_config") as mock_get_config:
+ mock_get_config.return_value = AttrDict(
+ {
+ "ignore_globs": ignore_patterns,
+ }
+ )
+
+ folder = Folder.from_path(tmp_dir_with_ignored_items, subdirs=False)
+
+ # Check that ignored files are not present
+ ignored_files = [".hidden_file.txt", "temp_file.tmp", "backup~"]
+ for file in ignored_files:
+ assert not any(os.path.basename(c.full_path) == file for c in folder.walk())
+
+ # Check that ignored directories are not present
+ ignored_dirs = [".hidden_dir", "temp_dir"]
+ for dir_name in ignored_dirs:
+ assert not any(
+ os.path.basename(c.full_path) == dir_name for c in folder.walk()
+ )
+
+ # Check that non-ignored items are present
+ assert any(
+ os.path.basename(c.full_path) == "visible_file.txt" for c in folder.walk()
+ )
+ assert any(
+ os.path.basename(c.full_path) == "visible_dir" for c in folder.walk()
+ )
+
+
+def test_from_path_ignores_nested_items(tmpdir):
+ """Test that Folder.from_path ignores nested files and folders."""
+ base = Path(tmpdir)
+
+ # Create a nested structure with ignored items
+ (base / "visible_dir").mkdir()
+ (base / "visible_dir" / ".hidden_nested").mkdir()
+ (base / "visible_dir" / ".hidden_nested" / "file.txt").touch()
+ (base / "visible_dir" / "temp_file.tmp").touch()
+ (base / "visible_dir" / "normal_file.txt").touch()
+
+ # Mock the config to return specific ignore patterns
+ ignore_patterns = ["*.tmp", ".*"]
+
+ with mock.patch("beets_flask.disk.get_config") as mock_get_config:
+ mock_get_config.return_value = AttrDict(
+ {
+ "ignore_globs": ignore_patterns,
+ }
+ )
+
+ folder = Folder.from_path(base, subdirs=True)
+
+ # Check that ignored nested items are not present
+ assert not any(
+ os.path.basename(c.full_path) == ".hidden_nested" for c in folder.walk()
+ )
+ assert not any(
+ os.path.basename(c.full_path) == "temp_file.tmp" for c in folder.walk()
+ )
+
+ # Check that non-ignored nested items are present
+ assert any(
+ os.path.basename(c.full_path) == "normal_file.txt" for c in folder.walk()
+ )
diff --git a/backend/tests/unit/test_importer/conftest.py b/backend/tests/unit/test_importer/conftest.py
index 5e284a6c..c1796949 100644
--- a/backend/tests/unit/test_importer/conftest.py
+++ b/backend/tests/unit/test_importer/conftest.py
@@ -1,137 +1,137 @@
-import logging
-import shutil
-from pathlib import Path
-
-import pytest
-
-log = logging.getLogger(__name__)
-log.setLevel(logging.DEBUG)
-
-
-VALID_PATHS = ["1991", "1991/Chant [SINGLE]", "Annix", "Annix/Antidote"]
-
-
-# Path relative to data/audio
-@pytest.fixture
-def album_paths(tmpdir_factory):
- # Create a temporary directory
- tmpdir = tmpdir_factory.mktemp("audio")
-
- destinations = []
-
- for path in VALID_PATHS:
- # Copy files
- source = album_path_absolute(path)
- destination = tmpdir / path
- # os.makedirs(destination, exist_ok=True)
-
- shutil.rmtree(destination, ignore_errors=True)
- shutil.copytree(source, destination)
- destinations.append(Path(destination))
-
- yield destinations
-
- # Clean up
- shutil.rmtree(tmpdir)
-
-
-def album_path_absolute(path: str):
- return Path(__file__).parent.parent.parent / "data" / "audio" / path
-
-
-def valid_data_for_album_path(path: str | Path) -> dict:
- """Return (a limited subset of) valid tag data for an album path."""
- if isinstance(path, Path):
- p = path
- else:
- p = album_path_absolute(path)
-
- if p.name in ["1991", "Chant [SINGLE]"]:
- return {
- "match_url": "https://musicbrainz.org/release/b0219c84-9277-4fdc-b054-aae4aae3dbbf",
- "match_album": "Chant",
- "match_artist": "1991",
- "num_tracks": 1,
- "album_folder_basename": p.name,
- "distance": 0.05714285714285714,
- }
- elif p.name in ["Annix", "Antidote"]:
- return {
- "match_url": "https://musicbrainz.org/release/a25664c1-6db7-43db-9e32-1f1f249dbecc",
- "match_album": "Antidote",
- "match_artist": "Annix",
- "num_tracks": 1,
- "album_folder_basename": p.name,
- "distance": 0.08888888888888889,
- }
- else:
- raise NotImplementedError(f"Unknown test album path {p=}")
-
-
-# ----------------- Monkeypath beets to use cached responses ----------------- #
-
-import hashlib
-import pickle
-
-from beets import autotag
-from beets.autotag import tag_album as _tag_album
-
-album_path: str
-
-
-def use_mock_tag_album(a_dir: str):
- """Use a cached lookup for the tag_album function in beets
- this allows to not make requests to the internet when testing
- the importer.
- """
- global album_path
- album_path = a_dir
-
- autotag.tag_album = tag_album
-
-
-def tag_album(
- items,
- search_artist: str | None = None,
- search_album: str | None = None,
- search_ids: list[str] = [],
-):
- global album_path
- log.debug(f"Using monkey patched lookup {album_path=}")
-
- # Compute items hash based on the items
-
- m = hashlib.md5()
- for item in items:
- m.update(item.path)
- if search_artist:
- m.update(search_artist.encode("utf-8"))
- if search_album:
- m.update(search_album.encode("utf-8"))
- for search_id in search_ids:
- m.update(search_id.encode("utf-8"))
- items_hash = m.hexdigest()[:8]
-
- if (Path(album_path) / f"lookup_{items_hash}.pickle").exists():
- log.debug(f"Using cached lookup {album_path=}")
- with open(Path(album_path) / f"lookup_{items_hash}.pickle", "rb") as f:
- return pickle.load(f)
-
- else:
- # TODO: This pickle contains absolute paths to the files
- # while undesired (no use in having them in the git repo) its for now the
- # easiest way... and we hope music brainz does not change its data too often!
- log.debug(f"Using default lookup {album_path=}")
- res = _tag_album(items, search_artist, search_album, search_ids)
-
- outdir = Path(album_path)
- if not outdir.is_dir():
- outdir = outdir.parent
-
- with open(outdir / f"lookup_{items_hash}.pickle", "wb") as f:
- pickle.dump(res, f)
-
- return res
-
-
-autotag.tag_album = tag_album
+import logging
+import shutil
+from pathlib import Path
+
+import pytest
+
+log = logging.getLogger(__name__)
+log.setLevel(logging.DEBUG)
+
+
+VALID_PATHS = ["1991", "1991/Chant [SINGLE]", "Annix", "Annix/Antidote"]
+
+
+# Path relative to data/audio
+@pytest.fixture
+def album_paths(tmpdir_factory):
+ # Create a temporary directory
+ tmpdir = tmpdir_factory.mktemp("audio")
+
+ destinations = []
+
+ for path in VALID_PATHS:
+ # Copy files
+ source = album_path_absolute(path)
+ destination = tmpdir / path
+ # os.makedirs(destination, exist_ok=True)
+
+ shutil.rmtree(destination, ignore_errors=True)
+ shutil.copytree(source, destination)
+ destinations.append(Path(destination))
+
+ yield destinations
+
+ # Clean up
+ shutil.rmtree(tmpdir)
+
+
+def album_path_absolute(path: str):
+ return Path(__file__).parent.parent.parent / "data" / "audio" / path
+
+
+def valid_data_for_album_path(path: str | Path) -> dict:
+ """Return (a limited subset of) valid tag data for an album path."""
+ if isinstance(path, Path):
+ p = path
+ else:
+ p = album_path_absolute(path)
+
+ if p.name in ["1991", "Chant [SINGLE]"]:
+ return {
+ "match_url": "https://musicbrainz.org/release/b0219c84-9277-4fdc-b054-aae4aae3dbbf",
+ "match_album": "Chant",
+ "match_artist": "1991",
+ "num_tracks": 1,
+ "album_folder_basename": p.name,
+ "distance": 0.05714285714285714,
+ }
+ elif p.name in ["Annix", "Antidote"]:
+ return {
+ "match_url": "https://musicbrainz.org/release/a25664c1-6db7-43db-9e32-1f1f249dbecc",
+ "match_album": "Antidote",
+ "match_artist": "Annix",
+ "num_tracks": 1,
+ "album_folder_basename": p.name,
+ "distance": 0.08888888888888889,
+ }
+ else:
+ raise NotImplementedError(f"Unknown test album path {p=}")
+
+
+# ----------------- Monkeypath beets to use cached responses ----------------- #
+
+import hashlib
+import pickle
+
+from beets import autotag
+from beets.autotag import tag_album as _tag_album
+
+album_path: str
+
+
+def use_mock_tag_album(a_dir: str):
+ """Use a cached lookup for the tag_album function in beets
+ this allows to not make requests to the internet when testing
+ the importer.
+ """
+ global album_path
+ album_path = a_dir
+
+ autotag.tag_album = tag_album
+
+
+def tag_album(
+ items,
+ search_artist: str | None = None,
+ search_album: str | None = None,
+ search_ids: list[str] = [],
+):
+ global album_path
+ log.debug(f"Using monkey patched lookup {album_path=}")
+
+ # Compute items hash based on the items
+
+ m = hashlib.md5()
+ for item in items:
+ m.update(item.path)
+ if search_artist:
+ m.update(search_artist.encode("utf-8"))
+ if search_album:
+ m.update(search_album.encode("utf-8"))
+ for search_id in search_ids:
+ m.update(search_id.encode("utf-8"))
+ items_hash = m.hexdigest()[:8]
+
+ if (Path(album_path) / f"lookup_{items_hash}.pickle").exists():
+ log.debug(f"Using cached lookup {album_path=}")
+ with open(Path(album_path) / f"lookup_{items_hash}.pickle", "rb") as f:
+ return pickle.load(f)
+
+ else:
+ # TODO: This pickle contains absolute paths to the files
+ # while undesired (no use in having them in the git repo) its for now the
+ # easiest way... and we hope music brainz does not change its data too often!
+ log.debug(f"Using default lookup {album_path=}")
+ res = _tag_album(items, search_artist, search_album, search_ids)
+
+ outdir = Path(album_path)
+ if not outdir.is_dir():
+ outdir = outdir.parent
+
+ with open(outdir / f"lookup_{items_hash}.pickle", "wb") as f:
+ pickle.dump(res, f)
+
+ return res
+
+
+autotag.tag_album = tag_album
diff --git a/backend/tests/unit/test_importer/test_asyncpipe.py b/backend/tests/unit/test_importer/test_asyncpipe.py
index 2ac5bfa0..5c55a83b 100644
--- a/backend/tests/unit/test_importer/test_asyncpipe.py
+++ b/backend/tests/unit/test_importer/test_asyncpipe.py
@@ -1,112 +1,112 @@
-import asyncio
-import logging
-import time
-
-import pytest
-
-from beets_flask.importer.pipeline import AsyncPipeline
-
-
-@pytest.mark.asyncio
-async def test_smoke_async(caplog):
- """Async version of the beets smoke test
- for pipeline
- """
-
- # no idea what happens with the test
- # when logging is configured differently
- log = logging.getLogger("logger")
- log.setLevel(logging.INFO)
-
- async def produce():
- for i in range(5):
- log.info(f"producing {i}")
- await asyncio.sleep(0.05)
- yield i
-
- async def work():
- num = yield
- while True:
- log.info(f"working {num}")
- num = yield num * 2
-
- async def consume():
- while True:
- num = yield
- await asyncio.sleep(0.05)
- log.info(f"consuming {num}")
-
- initial_task = produce()
- stages = [work(), consume()]
- pipeline: AsyncPipeline = AsyncPipeline(initial_task, stages)
-
- await pipeline.run_async()
- assert caplog.record_tuples == [
- ("logger", logging.INFO, "producing 0"),
- ("logger", logging.INFO, "working 0"),
- ("logger", logging.INFO, "consuming 0"),
- ("logger", logging.INFO, "producing 1"),
- ("logger", logging.INFO, "working 1"),
- ("logger", logging.INFO, "consuming 2"),
- ("logger", logging.INFO, "producing 2"),
- ("logger", logging.INFO, "working 2"),
- ("logger", logging.INFO, "consuming 4"),
- ("logger", logging.INFO, "producing 3"),
- ("logger", logging.INFO, "working 3"),
- ("logger", logging.INFO, "consuming 6"),
- ("logger", logging.INFO, "producing 4"),
- ("logger", logging.INFO, "working 4"),
- ("logger", logging.INFO, "consuming 8"),
- ]
-
-
-@pytest.mark.asyncio
-async def test_smoke_sync(caplog):
- """Async version of the beets smoke test
- for pipeline
- """
-
- log = logging.getLogger("logger")
- log.setLevel(logging.INFO)
-
- def produce():
- for i in range(5):
- log.info(f"producing {i}")
- time.sleep(0.05)
- yield i
-
- def work():
- num = yield
- while True:
- log.info(f"working {num}")
- time.sleep(0.05)
- num = yield num * 2
-
- def consume():
- while True:
- num = yield
- time.sleep(0.05)
- log.info(f"consuming {num}")
-
- initial_task = produce()
- stages = [work(), consume()]
- pipeline: AsyncPipeline = AsyncPipeline(initial_task, stages)
-
- await pipeline.run_async()
- assert caplog.record_tuples == [
- ("logger", logging.INFO, "producing 0"),
- ("logger", logging.INFO, "working 0"),
- ("logger", logging.INFO, "consuming 0"),
- ("logger", logging.INFO, "producing 1"),
- ("logger", logging.INFO, "working 1"),
- ("logger", logging.INFO, "consuming 2"),
- ("logger", logging.INFO, "producing 2"),
- ("logger", logging.INFO, "working 2"),
- ("logger", logging.INFO, "consuming 4"),
- ("logger", logging.INFO, "producing 3"),
- ("logger", logging.INFO, "working 3"),
- ("logger", logging.INFO, "consuming 6"),
- ("logger", logging.INFO, "producing 4"),
- ("logger", logging.INFO, "working 4"),
- ("logger", logging.INFO, "consuming 8"),
- ]
+import asyncio
+import logging
+import time
+
+import pytest
+
+from beets_flask.importer.pipeline import AsyncPipeline
+
+
+@pytest.mark.asyncio
+async def test_smoke_async(caplog):
+ """Async version of the beets smoke test
+ for pipeline
+ """
+
+ # no idea what happens with the test
+ # when logging is configured differently
+ log = logging.getLogger("logger")
+ log.setLevel(logging.INFO)
+
+ async def produce():
+ for i in range(5):
+ log.info(f"producing {i}")
+ await asyncio.sleep(0.05)
+ yield i
+
+ async def work():
+ num = yield
+ while True:
+ log.info(f"working {num}")
+ num = yield num * 2
+
+ async def consume():
+ while True:
+ num = yield
+ await asyncio.sleep(0.05)
+ log.info(f"consuming {num}")
+
+ initial_task = produce()
+ stages = [work(), consume()]
+ pipeline: AsyncPipeline = AsyncPipeline(initial_task, stages)
+
+ await pipeline.run_async()
+ assert caplog.record_tuples == [
+ ("logger", logging.INFO, "producing 0"),
+ ("logger", logging.INFO, "working 0"),
+ ("logger", logging.INFO, "consuming 0"),
+ ("logger", logging.INFO, "producing 1"),
+ ("logger", logging.INFO, "working 1"),
+ ("logger", logging.INFO, "consuming 2"),
+ ("logger", logging.INFO, "producing 2"),
+ ("logger", logging.INFO, "working 2"),
+ ("logger", logging.INFO, "consuming 4"),
+ ("logger", logging.INFO, "producing 3"),
+ ("logger", logging.INFO, "working 3"),
+ ("logger", logging.INFO, "consuming 6"),
+ ("logger", logging.INFO, "producing 4"),
+ ("logger", logging.INFO, "working 4"),
+ ("logger", logging.INFO, "consuming 8"),
+ ]
+
+
+@pytest.mark.asyncio
+async def test_smoke_sync(caplog):
+ """Async version of the beets smoke test
+ for pipeline
+ """
+
+ log = logging.getLogger("logger")
+ log.setLevel(logging.INFO)
+
+ def produce():
+ for i in range(5):
+ log.info(f"producing {i}")
+ time.sleep(0.05)
+ yield i
+
+ def work():
+ num = yield
+ while True:
+ log.info(f"working {num}")
+ time.sleep(0.05)
+ num = yield num * 2
+
+ def consume():
+ while True:
+ num = yield
+ time.sleep(0.05)
+ log.info(f"consuming {num}")
+
+ initial_task = produce()
+ stages = [work(), consume()]
+ pipeline: AsyncPipeline = AsyncPipeline(initial_task, stages)
+
+ await pipeline.run_async()
+ assert caplog.record_tuples == [
+ ("logger", logging.INFO, "producing 0"),
+ ("logger", logging.INFO, "working 0"),
+ ("logger", logging.INFO, "consuming 0"),
+ ("logger", logging.INFO, "producing 1"),
+ ("logger", logging.INFO, "working 1"),
+ ("logger", logging.INFO, "consuming 2"),
+ ("logger", logging.INFO, "producing 2"),
+ ("logger", logging.INFO, "working 2"),
+ ("logger", logging.INFO, "consuming 4"),
+ ("logger", logging.INFO, "producing 3"),
+ ("logger", logging.INFO, "working 3"),
+ ("logger", logging.INFO, "consuming 6"),
+ ("logger", logging.INFO, "producing 4"),
+ ("logger", logging.INFO, "working 4"),
+ ("logger", logging.INFO, "consuming 8"),
+ ]
diff --git a/backend/tests/unit/test_importer/test_progress.py b/backend/tests/unit/test_importer/test_progress.py
index 7894ed9c..3a1e4c04 100644
--- a/backend/tests/unit/test_importer/test_progress.py
+++ b/backend/tests/unit/test_importer/test_progress.py
@@ -1,79 +1,79 @@
-import pytest
-
-from beets_flask.importer.progress import Progress, ProgressState
-
-
-def test_progress_equality():
- assert Progress.NOT_STARTED == Progress.NOT_STARTED
- assert Progress.READING_FILES == Progress.READING_FILES
- assert Progress.IMPORT_COMPLETED == Progress.IMPORT_COMPLETED
- assert Progress.NOT_STARTED != Progress.READING_FILES
-
-
-def test_progress_less_than():
- assert Progress.NOT_STARTED < Progress.READING_FILES
- assert Progress.READING_FILES < Progress.GROUPING_ALBUMS
- assert not (Progress.IMPORT_COMPLETED < Progress.NOT_STARTED)
-
-
-def test_progress_subtraction():
- assert (Progress.GROUPING_ALBUMS - 1) == Progress.READING_FILES
- assert Progress.READING_FILES - 99 == Progress.NOT_STARTED
- assert (
- Progress.IMPORT_COMPLETED - 100 == Progress.NOT_STARTED
- ) # Test clamping to min
-
-
-def test_progress_addition():
- assert Progress.READING_FILES + 1 == Progress.GROUPING_ALBUMS
- assert (
- Progress.READING_FILES + 100 >= Progress.IMPORT_COMPLETED
- ) # Test clamping to max
- assert Progress.IMPORT_COMPLETED + (-5) == Progress.MANIPULATING_FILES
-
-
-# Test cases for ProgressState dataclass
-def test_progress_state_equality():
- state1 = ProgressState(Progress.NOT_STARTED)
- state2 = ProgressState(Progress.NOT_STARTED)
- state3 = ProgressState(Progress.READING_FILES)
-
- assert state1 == state2
- assert state1 != state3
- assert state1 == Progress.NOT_STARTED # Test against enum directly
- assert state3 == Progress.READING_FILES
-
-
-def test_progress_state_less_than():
- state1 = ProgressState(Progress.NOT_STARTED)
- state2 = ProgressState(Progress.READING_FILES)
- state3 = ProgressState(Progress.IMPORT_COMPLETED)
-
- assert state1 < state2
- assert state2 < state3
- assert not (state3 < state1)
- assert state1 < Progress.READING_FILES # Test against enum directly
- assert not (state3 < Progress.NOT_STARTED)
-
-
-def test_progress_greater_than():
- state1 = ProgressState(Progress.NOT_STARTED)
- state2 = ProgressState(Progress.READING_FILES)
- state3 = ProgressState(Progress.IMPORT_COMPLETED)
-
- assert state2 > state1
- assert state3 > state2
- assert not (state1 > state3)
- assert not (state1 > Progress.READING_FILES) # Test against enum directly
- assert not (Progress.NOT_STARTED > state3)
-
-
-def test_progress_invalid_comparison():
- with pytest.raises(Exception):
- # Comparing with non-Progress type
- Progress.NOT_STARTED < "invalid_type" # type: ignore
-
- state = ProgressState(Progress.NOT_STARTED)
- with pytest.raises(Exception):
- # Comparing with non-Progress/ProgressState type
- state < "invalid_type" # type: ignore
+import pytest
+
+from beets_flask.importer.progress import Progress, ProgressState
+
+
+def test_progress_equality():
+ assert Progress.NOT_STARTED == Progress.NOT_STARTED
+ assert Progress.READING_FILES == Progress.READING_FILES
+ assert Progress.IMPORT_COMPLETED == Progress.IMPORT_COMPLETED
+ assert Progress.NOT_STARTED != Progress.READING_FILES
+
+
+def test_progress_less_than():
+ assert Progress.NOT_STARTED < Progress.READING_FILES
+ assert Progress.READING_FILES < Progress.GROUPING_ALBUMS
+ assert not (Progress.IMPORT_COMPLETED < Progress.NOT_STARTED)
+
+
+def test_progress_subtraction():
+ assert (Progress.GROUPING_ALBUMS - 1) == Progress.READING_FILES
+ assert Progress.READING_FILES - 99 == Progress.NOT_STARTED
+ assert (
+ Progress.IMPORT_COMPLETED - 100 == Progress.NOT_STARTED
+ ) # Test clamping to min
+
+
+def test_progress_addition():
+ assert Progress.READING_FILES + 1 == Progress.GROUPING_ALBUMS
+ assert (
+ Progress.READING_FILES + 100 >= Progress.IMPORT_COMPLETED
+ ) # Test clamping to max
+ assert Progress.IMPORT_COMPLETED + (-5) == Progress.MANIPULATING_FILES
+
+
+# Test cases for ProgressState dataclass
+def test_progress_state_equality():
+ state1 = ProgressState(Progress.NOT_STARTED)
+ state2 = ProgressState(Progress.NOT_STARTED)
+ state3 = ProgressState(Progress.READING_FILES)
+
+ assert state1 == state2
+ assert state1 != state3
+ assert state1 == Progress.NOT_STARTED # Test against enum directly
+ assert state3 == Progress.READING_FILES
+
+
+def test_progress_state_less_than():
+ state1 = ProgressState(Progress.NOT_STARTED)
+ state2 = ProgressState(Progress.READING_FILES)
+ state3 = ProgressState(Progress.IMPORT_COMPLETED)
+
+ assert state1 < state2
+ assert state2 < state3
+ assert not (state3 < state1)
+ assert state1 < Progress.READING_FILES # Test against enum directly
+ assert not (state3 < Progress.NOT_STARTED)
+
+
+def test_progress_greater_than():
+ state1 = ProgressState(Progress.NOT_STARTED)
+ state2 = ProgressState(Progress.READING_FILES)
+ state3 = ProgressState(Progress.IMPORT_COMPLETED)
+
+ assert state2 > state1
+ assert state3 > state2
+ assert not (state1 > state3)
+ assert not (state1 > Progress.READING_FILES) # Test against enum directly
+ assert not (Progress.NOT_STARTED > state3)
+
+
+def test_progress_invalid_comparison():
+ with pytest.raises(Exception):
+ # Comparing with non-Progress type
+ Progress.NOT_STARTED < "invalid_type" # type: ignore
+
+ state = ProgressState(Progress.NOT_STARTED)
+ with pytest.raises(Exception):
+ # Comparing with non-Progress/ProgressState type
+ state < "invalid_type" # type: ignore
diff --git a/backend/tests/unit/test_importer/test_session.py b/backend/tests/unit/test_importer/test_session.py
index dba94180..b060e359 100644
--- a/backend/tests/unit/test_importer/test_session.py
+++ b/backend/tests/unit/test_importer/test_session.py
@@ -1,66 +1,66 @@
-import logging
-import os
-from pathlib import Path
-
-import pytest
-
-from beets_flask.importer.session import PreviewSession
-from beets_flask.importer.states import SessionState
-
-from .conftest import (
- VALID_PATHS,
- album_path_absolute,
- use_mock_tag_album,
-)
-
-log = logging.getLogger(__name__)
-log.setLevel(logging.DEBUG)
-
-
-@pytest.mark.skip(reason="This test is only for generating!")
-def test_generate_lookup():
- """Generate a lookup file for the albums.
- Uncomment the pytest.skip() to generate the lookup files.
-
- They should normally exist in the repository already.
- """
- for path in VALID_PATHS:
- p = Path(__file__).parent.parent.parent / "data" / "audio" / path
- use_mock_tag_album(str(p))
-
- state = SessionState(p)
- session = PreviewSession(state)
-
- state = session.run_sync()
- assert os.path.exists(p / "lookup.pickle")
-
-
-def test_album_exists(album_paths: list[Path]):
- """Just a check to test if the album_paths fixture
- is working as expected."""
- for ap in album_paths:
- assert ap.exists()
- assert ap.is_dir()
- assert len(list(ap.glob("**/*.mp3"))) > 0
-
-
-class TestPreviewSessions:
- def get_state(self, path: str):
- p = album_path_absolute(path)
- self.session = PreviewSession(SessionState(p))
- use_mock_tag_album(str(p))
- return self.session.run_sync()
-
- @pytest.mark.parametrize("path", VALID_PATHS)
- def test_candidates_url(self, path):
- state = self.get_state(path)
- for task in state.task_states:
- for candidate in task.candidate_states:
- if candidate.id.startswith("asis"):
- assert candidate.url is not None
- assert candidate.url.startswith("file://")
- else:
- assert candidate.url is not None
- assert candidate.url.startswith("https")
-
- log.debug(f"State: {state}")
+import logging
+import os
+from pathlib import Path
+
+import pytest
+
+from beets_flask.importer.session import PreviewSession
+from beets_flask.importer.states import SessionState
+
+from .conftest import (
+ VALID_PATHS,
+ album_path_absolute,
+ use_mock_tag_album,
+)
+
+log = logging.getLogger(__name__)
+log.setLevel(logging.DEBUG)
+
+
+@pytest.mark.skip(reason="This test is only for generating!")
+def test_generate_lookup():
+ """Generate a lookup file for the albums.
+ Uncomment the pytest.skip() to generate the lookup files.
+
+ They should normally exist in the repository already.
+ """
+ for path in VALID_PATHS:
+ p = Path(__file__).parent.parent.parent / "data" / "audio" / path
+ use_mock_tag_album(str(p))
+
+ state = SessionState(p)
+ session = PreviewSession(state)
+
+ state = session.run_sync()
+ assert os.path.exists(p / "lookup.pickle")
+
+
+def test_album_exists(album_paths: list[Path]):
+ """Just a check to test if the album_paths fixture
+ is working as expected."""
+ for ap in album_paths:
+ assert ap.exists()
+ assert ap.is_dir()
+ assert len(list(ap.glob("**/*.mp3"))) > 0
+
+
+class TestPreviewSessions:
+ def get_state(self, path: str):
+ p = album_path_absolute(path)
+ self.session = PreviewSession(SessionState(p))
+ use_mock_tag_album(str(p))
+ return self.session.run_sync()
+
+ @pytest.mark.parametrize("path", VALID_PATHS)
+ def test_candidates_url(self, path):
+ state = self.get_state(path)
+ for task in state.task_states:
+ for candidate in task.candidate_states:
+ if candidate.id.startswith("asis"):
+ assert candidate.url is not None
+ assert candidate.url.startswith("file://")
+ else:
+ assert candidate.url is not None
+ assert candidate.url.startswith("https")
+
+ log.debug(f"State: {state}")
diff --git a/backend/tests/unit/test_importer/test_stages.py b/backend/tests/unit/test_importer/test_stages.py
index 4f0a497d..c040d3e5 100644
--- a/backend/tests/unit/test_importer/test_stages.py
+++ b/backend/tests/unit/test_importer/test_stages.py
@@ -1,47 +1,47 @@
-import logging
-from typing import cast
-
-from beets_flask.importer.session import AutoImportSession, BaseSession
-from beets_flask.importer.stages import (
- StageOrder,
- identify_duplicates,
- match_threshold,
- user_query,
-)
-
-log = logging.getLogger(__name__)
-log.setLevel(logging.DEBUG)
-
-
-class DummySession(BaseSession):
- pass
-
-
-def test_stage_insert_order():
- stages = StageOrder()
-
- # Workaround to avoid mypy error
- dummySession: AutoImportSession = cast(AutoImportSession, None)
-
- stages.append(identify_duplicates(dummySession))
- stages.append(user_query(dummySession))
- stages.append(stage=user_query(dummySession), name="foo")
-
- assert len(stages) == 3
- assert list(stages.keys())[0] == "identify_duplicates"
- assert list(stages.keys())[1] == "user_query"
- assert list(stages.keys())[2] == "foo"
-
- stages.insert(
- after="identify_duplicates", stage=match_threshold(dummySession), name="bar"
- )
-
- assert len(stages) == 4
- assert list(stages.keys())[1] == "bar"
-
- stages.insert(
- before="identify_duplicates", stage=match_threshold(dummySession), name="baz"
- )
-
- assert len(stages) == 5
- assert list(stages.keys())[0] == "baz"
+import logging
+from typing import cast
+
+from beets_flask.importer.session import AutoImportSession, BaseSession
+from beets_flask.importer.stages import (
+ StageOrder,
+ identify_duplicates,
+ match_threshold,
+ user_query,
+)
+
+log = logging.getLogger(__name__)
+log.setLevel(logging.DEBUG)
+
+
+class DummySession(BaseSession):
+ pass
+
+
+def test_stage_insert_order():
+ stages = StageOrder()
+
+ # Workaround to avoid mypy error
+ dummySession: AutoImportSession = cast(AutoImportSession, None)
+
+ stages.append(identify_duplicates(dummySession))
+ stages.append(user_query(dummySession))
+ stages.append(stage=user_query(dummySession), name="foo")
+
+ assert len(stages) == 3
+ assert list(stages.keys())[0] == "identify_duplicates"
+ assert list(stages.keys())[1] == "user_query"
+ assert list(stages.keys())[2] == "foo"
+
+ stages.insert(
+ after="identify_duplicates", stage=match_threshold(dummySession), name="bar"
+ )
+
+ assert len(stages) == 4
+ assert list(stages.keys())[1] == "bar"
+
+ stages.insert(
+ before="identify_duplicates", stage=match_threshold(dummySession), name="baz"
+ )
+
+ assert len(stages) == 5
+ assert list(stages.keys())[0] == "baz"
diff --git a/backend/tests/unit/test_importer/test_states.py b/backend/tests/unit/test_importer/test_states.py
index dad53b57..69d62002 100644
--- a/backend/tests/unit/test_importer/test_states.py
+++ b/backend/tests/unit/test_importer/test_states.py
@@ -1,245 +1,245 @@
-import logging
-from abc import ABC
-from pathlib import Path
-
-import pytest
-from beets import autotag, importer
-
-from beets_flask.importer.states import (
- CandidateState,
- Progress,
- SessionState,
- TaskState,
-)
-from beets_flask.importer.types import BeetsAlbumMatch, BeetsTrackInfo
-from beets_flask.server.app import Encoder
-from tests.conftest import beets_lib_item
-from tests.mixins.database import IsolatedBeetsLibraryMixin, IsolatedDBMixin
-
-log = logging.getLogger(__name__)
-
-
-def get_album_match(tracks: list[BeetsTrackInfo], items, **info):
- match = BeetsAlbumMatch(
- distance=autotag.Distance(),
- info=autotag.AlbumInfo(
- tracks=tracks,
- **info,
- ),
- extra_items=[],
- extra_tracks=[],
- mapping={i: tracks[idx] for idx, i in enumerate(items)},
- )
- return match
-
-
-class StateTest(IsolatedBeetsLibraryMixin, IsolatedDBMixin, ABC):
- """Base class for tests that require a beets library."""
-
- @property
- def import_task(self):
- item = beets_lib_item(title="title", path="path")
- task = importer.ImportTask(paths=[b"a path"], toppath=b"top path", items=[item])
-
- track_info = autotag.TrackInfo(title="match title")
- album_match = get_album_match(
- [track_info], [item], album="match album", data_url="url"
- )
-
- task.candidates = [album_match]
- return task
-
-
-class TestTaskState(StateTest):
- task: importer.ImportTask
- task_state: TaskState
-
- @pytest.fixture(autouse=True)
- def gen_task_state(self):
- self.task = self.import_task
- self.task_state = TaskState(self.task)
-
- def test_properties(self):
- task_state = self.task_state
- assert task_state.id is not None
- assert task_state.toppath == Path("top path")
- assert task_state.paths == [Path("a path")]
- assert task_state.items == [self.task.items[0]]
- assert task_state.progress == Progress.NOT_STARTED
-
- assert len(task_state.candidate_states) == len(self.task.candidates)
-
- def test_best_candidate(self):
- task_state = self.task_state
- assert task_state.best_candidate_state is not None
- assert task_state.best_candidate_state.match is self.task.candidates[0]
-
- self.task.candidates = []
- task_state = TaskState(self.task)
- # Should be none if no candidates (asis is not counted!)
- assert task_state.best_candidate_state is None
-
- def test_serialize(self):
- task_state = self.task_state
- serialized = task_state.serialize()
- assert isinstance(serialized, dict)
- assert serialized["id"] == task_state.id
- assert serialized["toppath"] == str(task_state.toppath)
- assert serialized["paths"] == [str(p) for p in task_state.paths]
-
- # Can be serialized with json.dumps and Encoder
- import json
-
- json.dumps(serialized, cls=Encoder)
-
-
-class TestCandidateState(StateTest):
- task: importer.ImportTask
- task_state: TaskState
- candidates: list[CandidateState]
-
- @pytest.fixture(autouse=True)
- def gen_candidate_state(self):
- import_task = self.import_task
- task_state = TaskState(import_task)
- candidate_states = task_state.candidate_states
-
- assert len(candidate_states) == 1 # One from import_task
- self.candidates = candidate_states
- self.task = import_task
- self.task_state = task_state
-
- def test_properties(self):
- candidate = self.candidates[0]
- task = self.task
-
- assert isinstance(candidate, CandidateState)
- assert candidate.id is not None
- assert candidate.match == task.candidates[0]
- assert candidate.task_state == self.task_state
- assert candidate.type == "album"
- assert candidate.cur_artist == str(task.cur_artist)
- assert candidate.cur_album == str(task.cur_album)
- assert candidate.items == task.items
- assert candidate.tracks == candidate.match.info.tracks
- assert candidate.distance == candidate.match.distance
- assert candidate.num_tracks == len(candidate.match.info.tracks)
- assert candidate.num_items == len(candidate.items)
- assert candidate.url == task.candidates[0].info.data_url
- assert candidate.url == "url"
-
- # _mapping is set statically in the user_query stage, not via fixture.
- # but mapping has a fallback that uses candidate.match.mapping
- assert candidate._mapping == candidate.current_mapping
- assert candidate.mapping == {0: 0}
-
- def test_asis_candidate(self):
- # Test asis candidate (last in list)
- asis_candidate = self.task_state.asis_candidate
- assert self.task_state.asis_candidate_id == asis_candidate.id
- assert asis_candidate.id.startswith("asis")
- assert asis_candidate.type == "album"
-
- def test_diff_preview(self):
- candidate = self.candidates[0]
- diff_preview = candidate.diff_preview
- assert isinstance(diff_preview, str)
- assert "match album" in diff_preview
-
- def test_identify_duplicates(
- self,
- ):
- candidate = self.candidates[0]
- duplicates = candidate.identify_duplicates(self.beets_lib)
- assert isinstance(duplicates, list)
- assert len(duplicates) == 0
- assert candidate.has_duplicates_in_library is False
-
- def test_serialize(self):
- candidate = self.candidates[0]
- serialized = candidate.serialize()
- assert isinstance(serialized, dict)
- assert serialized["id"] == candidate.id
- assert serialized["penalties"] == candidate.penalties
- assert serialized["type"] == candidate.type
- assert serialized["distance"] == candidate.distance.distance
-
- # Can be serialized with json.dumps and Encoder
- import json
-
- json.dumps(serialized, cls=Encoder)
-
-
-class TestSessionState(StateTest):
- task: importer.ImportTask
- session_state: SessionState
-
- @pytest.fixture(autouse=True)
- def gen_session_state(self, tmpdir_factory: pytest.TempdirFactory):
- self.session_state = SessionState(
- Path(tmpdir_factory.mktemp("beets_flask_disk"))
- )
- self.task = self.import_task
- self.session_state.upsert_task(self.task)
-
- def test_multiple_upserts(self):
- import_task = self.task
- session_state = self.session_state
- session_state.upsert_task(import_task)
- session_state.upsert_task(import_task)
- assert len(session_state.task_states) == 1
-
- def test_progress(self):
- session_state = self.session_state
-
- assert session_state.progress == Progress.NOT_STARTED
-
- for task in session_state.task_states:
- assert task.progress == Progress.NOT_STARTED
- task.set_progress(Progress.IMPORTING)
-
- # Should be minimal progress of all tasks i.e. IMPORTING
- assert session_state.progress == Progress.IMPORTING
-
- # Should return notstarted if no tasks
- session_state._task_states = []
- assert session_state.progress == Progress.NOT_STARTED
-
- def test_get_task(self):
- import_task = self.task
- session_state = self.session_state
- task_state = session_state.get_task_state_for_task(import_task)
- assert task_state is not None
- assert task_state.task is import_task
-
- # By id
- task_state = session_state.get_task_state_by_id(task_state.id)
- assert task_state is not None
- assert task_state.task is import_task
-
- def test_serialize(self):
- session_state = self.session_state
- serialized = session_state.serialize()
- assert isinstance(serialized, dict)
- assert serialized["id"] == session_state.id
-
- tasks_loaded = serialized["tasks"]
- tasks_current = [t.serialize() for t in session_state.task_states]
- assert len(tasks_loaded) == len(tasks_current)
-
- # the asis candidate sets the date freshly every time.
- for t_l, t_c in zip(tasks_loaded, tasks_current):
- t_c["asis_candidate"].pop("created_at") # type: ignore[misc]
- t_c["asis_candidate"].pop("updated_at") # type: ignore[misc]
- t_l["asis_candidate"].pop("created_at") # type: ignore[misc]
- t_l["asis_candidate"].pop("updated_at") # type: ignore[misc]
- assert tasks_loaded == tasks_current
-
- assert serialized["status"]["message"] is None
- assert serialized["status"]["progress"] == Progress.NOT_STARTED
- assert serialized["status"]["plugin_name"] == None
-
- # Can be serialized with json.dumps and Encoder
- import json
-
- json.dumps(serialized, cls=Encoder)
+import logging
+from abc import ABC
+from pathlib import Path
+
+import pytest
+from beets import autotag, importer
+
+from beets_flask.importer.states import (
+ CandidateState,
+ Progress,
+ SessionState,
+ TaskState,
+)
+from beets_flask.importer.types import BeetsAlbumMatch, BeetsTrackInfo
+from beets_flask.server.app import Encoder
+from tests.conftest import beets_lib_item
+from tests.mixins.database import IsolatedBeetsLibraryMixin, IsolatedDBMixin
+
+log = logging.getLogger(__name__)
+
+
+def get_album_match(tracks: list[BeetsTrackInfo], items, **info):
+ match = BeetsAlbumMatch(
+ distance=autotag.Distance(),
+ info=autotag.AlbumInfo(
+ tracks=tracks,
+ **info,
+ ),
+ extra_items=[],
+ extra_tracks=[],
+ mapping={i: tracks[idx] for idx, i in enumerate(items)},
+ )
+ return match
+
+
+class StateTest(IsolatedBeetsLibraryMixin, IsolatedDBMixin, ABC):
+ """Base class for tests that require a beets library."""
+
+ @property
+ def import_task(self):
+ item = beets_lib_item(title="title", path="path")
+ task = importer.ImportTask(paths=[b"a path"], toppath=b"top path", items=[item])
+
+ track_info = autotag.TrackInfo(title="match title")
+ album_match = get_album_match(
+ [track_info], [item], album="match album", data_url="url"
+ )
+
+ task.candidates = [album_match]
+ return task
+
+
+class TestTaskState(StateTest):
+ task: importer.ImportTask
+ task_state: TaskState
+
+ @pytest.fixture(autouse=True)
+ def gen_task_state(self):
+ self.task = self.import_task
+ self.task_state = TaskState(self.task)
+
+ def test_properties(self):
+ task_state = self.task_state
+ assert task_state.id is not None
+ assert task_state.toppath == Path("top path")
+ assert task_state.paths == [Path("a path")]
+ assert task_state.items == [self.task.items[0]]
+ assert task_state.progress == Progress.NOT_STARTED
+
+ assert len(task_state.candidate_states) == len(self.task.candidates)
+
+ def test_best_candidate(self):
+ task_state = self.task_state
+ assert task_state.best_candidate_state is not None
+ assert task_state.best_candidate_state.match is self.task.candidates[0]
+
+ self.task.candidates = []
+ task_state = TaskState(self.task)
+ # Should be none if no candidates (asis is not counted!)
+ assert task_state.best_candidate_state is None
+
+ def test_serialize(self):
+ task_state = self.task_state
+ serialized = task_state.serialize()
+ assert isinstance(serialized, dict)
+ assert serialized["id"] == task_state.id
+ assert serialized["toppath"] == str(task_state.toppath)
+ assert serialized["paths"] == [str(p) for p in task_state.paths]
+
+ # Can be serialized with json.dumps and Encoder
+ import json
+
+ json.dumps(serialized, cls=Encoder)
+
+
+class TestCandidateState(StateTest):
+ task: importer.ImportTask
+ task_state: TaskState
+ candidates: list[CandidateState]
+
+ @pytest.fixture(autouse=True)
+ def gen_candidate_state(self):
+ import_task = self.import_task
+ task_state = TaskState(import_task)
+ candidate_states = task_state.candidate_states
+
+ assert len(candidate_states) == 1 # One from import_task
+ self.candidates = candidate_states
+ self.task = import_task
+ self.task_state = task_state
+
+ def test_properties(self):
+ candidate = self.candidates[0]
+ task = self.task
+
+ assert isinstance(candidate, CandidateState)
+ assert candidate.id is not None
+ assert candidate.match == task.candidates[0]
+ assert candidate.task_state == self.task_state
+ assert candidate.type == "album"
+ assert candidate.cur_artist == str(task.cur_artist)
+ assert candidate.cur_album == str(task.cur_album)
+ assert candidate.items == task.items
+ assert candidate.tracks == candidate.match.info.tracks
+ assert candidate.distance == candidate.match.distance
+ assert candidate.num_tracks == len(candidate.match.info.tracks)
+ assert candidate.num_items == len(candidate.items)
+ assert candidate.url == task.candidates[0].info.data_url
+ assert candidate.url == "url"
+
+ # _mapping is set statically in the user_query stage, not via fixture.
+ # but mapping has a fallback that uses candidate.match.mapping
+ assert candidate._mapping == candidate.current_mapping
+ assert candidate.mapping == {0: 0}
+
+ def test_asis_candidate(self):
+ # Test asis candidate (last in list)
+ asis_candidate = self.task_state.asis_candidate
+ assert self.task_state.asis_candidate_id == asis_candidate.id
+ assert asis_candidate.id.startswith("asis")
+ assert asis_candidate.type == "album"
+
+ def test_diff_preview(self):
+ candidate = self.candidates[0]
+ diff_preview = candidate.diff_preview
+ assert isinstance(diff_preview, str)
+ assert "match album" in diff_preview
+
+ def test_identify_duplicates(
+ self,
+ ):
+ candidate = self.candidates[0]
+ duplicates = candidate.identify_duplicates(self.beets_lib)
+ assert isinstance(duplicates, list)
+ assert len(duplicates) == 0
+ assert candidate.has_duplicates_in_library is False
+
+ def test_serialize(self):
+ candidate = self.candidates[0]
+ serialized = candidate.serialize()
+ assert isinstance(serialized, dict)
+ assert serialized["id"] == candidate.id
+ assert serialized["penalties"] == candidate.penalties
+ assert serialized["type"] == candidate.type
+ assert serialized["distance"] == candidate.distance.distance
+
+ # Can be serialized with json.dumps and Encoder
+ import json
+
+ json.dumps(serialized, cls=Encoder)
+
+
+class TestSessionState(StateTest):
+ task: importer.ImportTask
+ session_state: SessionState
+
+ @pytest.fixture(autouse=True)
+ def gen_session_state(self, tmpdir_factory: pytest.TempdirFactory):
+ self.session_state = SessionState(
+ Path(tmpdir_factory.mktemp("beets_flask_disk"))
+ )
+ self.task = self.import_task
+ self.session_state.upsert_task(self.task)
+
+ def test_multiple_upserts(self):
+ import_task = self.task
+ session_state = self.session_state
+ session_state.upsert_task(import_task)
+ session_state.upsert_task(import_task)
+ assert len(session_state.task_states) == 1
+
+ def test_progress(self):
+ session_state = self.session_state
+
+ assert session_state.progress == Progress.NOT_STARTED
+
+ for task in session_state.task_states:
+ assert task.progress == Progress.NOT_STARTED
+ task.set_progress(Progress.IMPORTING)
+
+ # Should be minimal progress of all tasks i.e. IMPORTING
+ assert session_state.progress == Progress.IMPORTING
+
+ # Should return notstarted if no tasks
+ session_state._task_states = []
+ assert session_state.progress == Progress.NOT_STARTED
+
+ def test_get_task(self):
+ import_task = self.task
+ session_state = self.session_state
+ task_state = session_state.get_task_state_for_task(import_task)
+ assert task_state is not None
+ assert task_state.task is import_task
+
+ # By id
+ task_state = session_state.get_task_state_by_id(task_state.id)
+ assert task_state is not None
+ assert task_state.task is import_task
+
+ def test_serialize(self):
+ session_state = self.session_state
+ serialized = session_state.serialize()
+ assert isinstance(serialized, dict)
+ assert serialized["id"] == session_state.id
+
+ tasks_loaded = serialized["tasks"]
+ tasks_current = [t.serialize() for t in session_state.task_states]
+ assert len(tasks_loaded) == len(tasks_current)
+
+ # the asis candidate sets the date freshly every time.
+ for t_l, t_c in zip(tasks_loaded, tasks_current):
+ t_c["asis_candidate"].pop("created_at") # type: ignore[misc]
+ t_c["asis_candidate"].pop("updated_at") # type: ignore[misc]
+ t_l["asis_candidate"].pop("created_at") # type: ignore[misc]
+ t_l["asis_candidate"].pop("updated_at") # type: ignore[misc]
+ assert tasks_loaded == tasks_current
+
+ assert serialized["status"]["message"] is None
+ assert serialized["status"]["progress"] == Progress.NOT_STARTED
+ assert serialized["status"]["plugin_name"] == None
+
+ # Can be serialized with json.dumps and Encoder
+ import json
+
+ json.dumps(serialized, cls=Encoder)
diff --git a/backend/tests/unit/test_redis.py b/backend/tests/unit/test_redis.py
index 940a0f2f..44e844a7 100644
--- a/backend/tests/unit/test_redis.py
+++ b/backend/tests/unit/test_redis.py
@@ -1,69 +1,69 @@
-from time import sleep
-
-from rq.job import Job
-
-import beets_flask.redis
-
-
-# global `from` import do not work in tests with mock
-# from beets_flask.redis import import_queue, preview_queue
-def f():
- sleep(0.2)
-
-
-class TestRedisMock:
- def test_enqueue_global_import(self):
- """Tests that enqueue works as expected in the
- test setup.
-
- I.e. should run instantly and not be queued.
- """
-
- assert beets_flask.redis.import_queue.is_async is False
- assert beets_flask.redis.preview_queue.is_async is False
-
- job = beets_flask.redis.import_queue.enqueue(f)
- assert isinstance(job, Job)
- assert job.result is None
- assert job.is_finished is True
-
- job = beets_flask.redis.preview_queue.enqueue(f)
- assert isinstance(job, Job)
- assert job.result is None
- assert job.is_finished is True
-
- def test_enqueue_local_import(self):
- """Tests that enqueue works as expected in the
- test setup.
-
- I.e. should run instantly and not be queued.
- """
- from beets_flask.redis import import_queue, preview_queue
-
- assert import_queue.is_async is False
- assert preview_queue.is_async is False
-
- job = import_queue.enqueue(f)
- assert isinstance(job, Job)
- assert job.result is None
- assert job.is_finished is True
-
- job = preview_queue.enqueue(f)
- assert isinstance(job, Job)
- assert job.result is None
- assert job.is_finished is True
-
-
-async def test_wait_for_job_results():
- """Test the wait_for_job_results function.
-
- Does not make too much sense as jobs are executed synchronously in tests
- but should still work.
- """
- from beets_flask.redis import import_queue, wait_for_job_results
-
- job = import_queue.enqueue(f)
- result = await wait_for_job_results(job, poll_interval=0.1, timeout=1)
- assert result is None
- assert job.result is None
- assert job.is_finished is True
+from time import sleep
+
+from rq.job import Job
+
+import beets_flask.redis
+
+
+# global `from` import do not work in tests with mock
+# from beets_flask.redis import import_queue, preview_queue
+def f():
+ sleep(0.2)
+
+
+class TestRedisMock:
+ def test_enqueue_global_import(self):
+ """Tests that enqueue works as expected in the
+ test setup.
+
+ I.e. should run instantly and not be queued.
+ """
+
+ assert beets_flask.redis.import_queue.is_async is False
+ assert beets_flask.redis.preview_queue.is_async is False
+
+ job = beets_flask.redis.import_queue.enqueue(f)
+ assert isinstance(job, Job)
+ assert job.result is None
+ assert job.is_finished is True
+
+ job = beets_flask.redis.preview_queue.enqueue(f)
+ assert isinstance(job, Job)
+ assert job.result is None
+ assert job.is_finished is True
+
+ def test_enqueue_local_import(self):
+ """Tests that enqueue works as expected in the
+ test setup.
+
+ I.e. should run instantly and not be queued.
+ """
+ from beets_flask.redis import import_queue, preview_queue
+
+ assert import_queue.is_async is False
+ assert preview_queue.is_async is False
+
+ job = import_queue.enqueue(f)
+ assert isinstance(job, Job)
+ assert job.result is None
+ assert job.is_finished is True
+
+ job = preview_queue.enqueue(f)
+ assert isinstance(job, Job)
+ assert job.result is None
+ assert job.is_finished is True
+
+
+async def test_wait_for_job_results():
+ """Test the wait_for_job_results function.
+
+ Does not make too much sense as jobs are executed synchronously in tests
+ but should still work.
+ """
+ from beets_flask.redis import import_queue, wait_for_job_results
+
+ job = import_queue.enqueue(f)
+ result = await wait_for_job_results(job, poll_interval=0.1, timeout=1)
+ assert result is None
+ assert job.result is None
+ assert job.is_finished is True
diff --git a/backend/tests/unit/test_setup.py b/backend/tests/unit/test_setup.py
index b08ef9bc..393270e7 100644
--- a/backend/tests/unit/test_setup.py
+++ b/backend/tests/unit/test_setup.py
@@ -1,23 +1,23 @@
-import os
-
-from beets_flask.logger import log
-
-
-def test_log():
- """Test that logger is correctly set up for testing."""
-
- assert "PYTEST_CURRENT_TEST" in os.environ
-
- # Logger should have no handlers
- assert not log.handlers
- assert log.level == 10
- assert log.name == "beets-flask"
-
-
-def test_config():
- """Test that config is correctly set up for testing."""
- import tempfile
-
- dir = os.environ.get("BEETSFLASKDIR")
- assert dir is not None
- assert str(tempfile.tempdir) in dir
+import os
+
+from beets_flask.logger import log
+
+
+def test_log():
+ """Test that logger is correctly set up for testing."""
+
+ assert "PYTEST_CURRENT_TEST" in os.environ
+
+ # Logger should have no handlers
+ assert not log.handlers
+ assert log.level == 10
+ assert log.name == "beets-flask"
+
+
+def test_config():
+ """Test that config is correctly set up for testing."""
+ import tempfile
+
+ dir = os.environ.get("BEETSFLASKDIR")
+ assert dir is not None
+ assert str(tempfile.tempdir) in dir
diff --git a/config/beets-flask/config.yaml b/config/beets-flask/config.yaml
new file mode 100644
index 00000000..39e34840
--- /dev/null
+++ b/config/beets-flask/config.yaml
@@ -0,0 +1,23 @@
+gui:
+ num_preview_workers: 4 # how many previews to generate in parallel
+
+ library:
+ # Use to split artists in the library view if using multiple artists in a field.
+ # Set to an empty list to disable this feature.
+ artist_separators: [",", ";", "&"]
+
+ terminal:
+ start_path: "/music/inbox" # the directory where to start new terminal sessions
+
+ inbox:
+ ignore:
+ - "@eaDir"
+ - "@SynoEAStream"
+ - ".*"
+ - "*~"
+ folders:
+ Inbox1:
+ name: "Inbox"
+ path: "/volume1/docker/Music/MusicToTag"
+ autotag: "preview"
+ # trigger tag but do not import, recommended for most control
diff --git a/config/beets/beetsplug/__init__.py b/config/beets/beetsplug/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/config/beets/beetsplug/genreexpand.py b/config/beets/beetsplug/genreexpand.py
new file mode 100644
index 00000000..e89a2cc5
--- /dev/null
+++ b/config/beets/beetsplug/genreexpand.py
@@ -0,0 +1,140 @@
+"""Expand genre tags to include their full hierarchy from a YAML genre tree.
+
+After lastgenre writes a genre (e.g. "Plugg"), this plugin walks up the
+tree defined in genres.yaml and rewrites the tag with all ancestors included
+(e.g. "Hip-Hop, Trap, Plugg"). It also normalises common Last.fm tag
+variants via the aliases section of that file.
+
+Flex attributes written to each item/album:
+ genre_pre_expand — genre string as returned by lastgenre, before expansion
+ genre_unrecognized — comma-separated list of genres not found in genres.yaml
+"""
+
+import yaml
+from beets.plugins import BeetsPlugin
+
+
+class GenreExpandPlugin(BeetsPlugin):
+ def __init__(self):
+ super().__init__()
+ self.config.add({
+ "genres_file": "genres.yaml",
+ "separator": ", ",
+ })
+ self._loaded = False
+ self._aliases = {}
+ # Maps canonical_name.lower() -> (canonical_name, parent_canonical_name | None)
+ self._parent_map = {}
+
+ self.register_listener("album_imported", self.on_album_imported)
+ self.register_listener("item_imported", self.on_item_imported)
+
+ # ------------------------------------------------------------------
+ # Setup
+ # ------------------------------------------------------------------
+
+ def _load(self):
+ if self._loaded:
+ return
+ path = self.config["genres_file"].as_filename()
+ with open(path) as f:
+ data = yaml.safe_load(f)
+ self._aliases = {
+ k.lower(): v for k, v in data.get("aliases", {}).items()
+ }
+ self._build_parent_map(data.get("tree", {}), parent=None)
+ self._loaded = True
+
+ def _build_parent_map(self, subtree, parent):
+ if not subtree:
+ return
+ for genre, children in subtree.items():
+ self._parent_map[genre.lower()] = (genre, parent)
+ if children:
+ self._build_parent_map(children, parent=genre)
+
+ # ------------------------------------------------------------------
+ # Genre logic
+ # ------------------------------------------------------------------
+
+ def _normalize(self, raw):
+ """Map a raw Last.fm tag to its canonical name."""
+ lower = raw.strip().lower()
+ if lower in self._aliases:
+ return self._aliases[lower]
+ if lower in self._parent_map:
+ return self._parent_map[lower][0]
+ return raw.strip()
+
+ def _is_known(self, genre):
+ return genre.lower() in self._parent_map
+
+ def _ancestors(self, genre):
+ """Return [root, ..., parent, genre] for a known genre, else [genre]."""
+ lower = genre.lower()
+ if lower not in self._parent_map:
+ return [genre]
+ chain = []
+ while lower in self._parent_map:
+ canonical, parent = self._parent_map[lower]
+ chain.append(canonical)
+ if parent is None:
+ break
+ lower = parent.lower()
+ return list(reversed(chain))
+
+ def _expand(self, genre_str, context):
+ """Normalise and expand a comma-separated genre string.
+
+ Returns (expanded_str, unknown_genres) where unknown_genres is a list
+ of genre names that were not found in genres.yaml.
+ """
+ self._load()
+ separator = self.config["separator"].get(str)
+ seen = set()
+ result = []
+ unknown = []
+ for raw in genre_str.split(","):
+ canonical = self._normalize(raw)
+ if not self._is_known(canonical):
+ self._log.warning(
+ "Unknown genre {!r} on {} — add it to genres.yaml and "
+ "re-import to get hierarchy expansion",
+ canonical, context,
+ )
+ unknown.append(canonical)
+ for g in self._ancestors(canonical):
+ if g.lower() not in seen:
+ seen.add(g.lower())
+ result.append(g)
+ expanded = separator.join(result) if result else genre_str
+ return expanded, unknown
+
+ def _apply(self, obj, context):
+ """Expand genre on a beets Item or Album, storing flex attributes."""
+ if not obj.genre:
+ self._log.warning("No genre found for {}", context)
+ return
+ pre_expand = obj.genre
+ obj.genre, unknown = self._expand(obj.genre, context)
+ obj.genre_pre_expand = pre_expand
+ obj.genre_unrecognized = ", ".join(unknown) if unknown else ""
+ obj.store()
+
+ # ------------------------------------------------------------------
+ # Event handlers
+ # ------------------------------------------------------------------
+
+ def on_album_imported(self, lib, album):
+ context = "{} - {}".format(album.albumartist, album.album)
+ self._apply(album, context)
+ # Propagate flex attributes to individual tracks (genre itself is
+ # inherited by beets via album.store(), but flex attrs are not)
+ for item in album.items():
+ item.genre_pre_expand = album.genre_pre_expand
+ item.genre_unrecognized = album.genre_unrecognized
+ item.store()
+
+ def on_item_imported(self, lib, item):
+ context = "{} - {}".format(item.artist, item.title)
+ self._apply(item, context)
diff --git a/config/beets/config.yaml b/config/beets/config.yaml
new file mode 100644
index 00000000..98894dc9
--- /dev/null
+++ b/config/beets/config.yaml
@@ -0,0 +1,106 @@
+plugins: [
+ info,
+ the,
+ fetchart,
+ embedart,
+ ftintitle,
+ lastgenre,
+ genreexpand,
+ missing,
+ albumtypes,
+ scrub,
+ zero,
+ mbsync,
+ duplicates,
+ convert,
+ fromfilename,
+ inline,
+ edit,
+ musicbrainz,
+ ]
+
+pluginpath:
+ - /config/beets
+
+directory: /volume1/docker/Music/Music
+# library: /config/beets/library.db # default location in the container
+
+import:
+ move: no
+ copy: yes
+ write: yes
+ log: /volume1/docker/Music/last_beets_imports.log
+ quiet_fallback: skip
+ detail: yes
+ duplicate_action: ask # ask|skip|merge|keep|remove
+
+ui:
+ color: yes
+
+# fix up output file paths
+replace:
+ '[\\]': ""
+ "[_]": "-"
+ "[/]": "-"
+ '^\.+': ""
+ '[\x00-\x1f]': ""
+ '[<>:"\?\*\|]': ""
+ '\.$': ""
+ '\s+$': ""
+ '^\s+': ""
+ "^-": ""
+ "'": ""
+ "′": ""
+ "″": ""
+ "‐": "-"
+
+types:
+ genre_pre_expand: string
+ genre_unrecognized: string
+
+per_disc_numbering: no
+asciify_paths: yes
+
+threaded: no
+
+fetchart:
+ minwidth: 500
+ enforce_ratio: 10px
+ sources: coverart filesystem itunes amazon albumart fanarttv
+
+embedart:
+ auto: yes
+ ifempty: yes
+ remove_art_file: yes
+
+ftintitle:
+ auto: yes
+ format: (feat. {0})
+
+lastgenre:
+ auto: yes
+ count: 4
+ prefer_specific: yes
+ force: yes
+ source: track
+ separator: ", "
+ fallback: ""
+
+genreexpand:
+ genres_file: /config/beets/genres.yaml
+ separator: ", "
+
+match:
+ strong_rec_thresh: 0.1
+ distance_weights:
+ data_source: 0.0
+ missing_tracks: 0.2
+
+musicbrainz:
+ external_ids:
+ discogs: yes
+ bandcamp: yes
+ spotify: yes
+ deezer: yes
+ beatport: yes
+ tidal: yes
diff --git a/config/beets/genres.yaml b/config/beets/genres.yaml
new file mode 100644
index 00000000..e720677c
--- /dev/null
+++ b/config/beets/genres.yaml
@@ -0,0 +1,142 @@
+# Genre hierarchy. Each key is a genre; its children are more specific sub-genres.
+# Null (~) marks a leaf with no sub-genres defined yet.
+#
+# When a track is tagged with a sub-genre, the plugin automatically
+# expands it to include all ancestors. For example, "Plugg" -> "Hip-Hop, Trap, Plugg".
+
+tree:
+ Hip-Hop:
+ Trap:
+ Plugg: ~
+ "Mumble Rap": ~
+ "SoundCloud Rap": ~
+ "Phonk": ~
+ Drill:
+ "UK Drill": ~
+ "Brooklyn Drill": ~
+ "Boom Bap": ~
+ "Lo-Fi Hip-Hop": ~
+ "G-Funk": ~
+ Crunk: ~
+ "Conscious Hip-Hop": ~
+
+ Electronic:
+ House:
+ "Deep House": ~
+ "Tech House": ~
+ "Future House": ~
+ "Afro House": ~
+ Techno: ~
+ "Drum and Bass":
+ Jungle: ~
+ Ambient: ~
+ "Trip-Hop": ~
+ IDM: ~
+ Trance:
+ "Progressive Trance": ~
+ Dubstep:
+ Brostep: ~
+ "Synth-Pop": ~
+
+ Rock:
+ "Indie Rock": ~
+ "Alternative Rock": ~
+ "Post-Punk": ~
+ "Post-Rock": ~
+ Metal:
+ "Heavy Metal": ~
+ "Black Metal": ~
+ "Death Metal": ~
+ "Doom Metal": ~
+
+ Pop:
+ "Indie Pop": ~
+ "Dream Pop": ~
+ "Art Pop": ~
+ "Synth-Pop": ~
+
+ R&B:
+ "Neo-Soul": ~
+
+ Soul: ~
+
+ Funk: ~
+
+ Jazz:
+ Bebop: ~
+ "Free Jazz": ~
+ "Jazz Fusion": ~
+
+ Classical: ~
+
+ Folk:
+ "Indie Folk": ~
+ Country:
+ "Alt-Country": ~
+
+# Normalization aliases. Maps common Last.fm tag variants to the canonical
+# genre names defined in the tree above. All keys must be lowercase.
+
+aliases:
+ "hip hop": "Hip-Hop"
+ "trap": "Trap"
+ "drill": "Drill"
+ "boom bap": "Boom Bap"
+ "gangsta": "Hip-Hop"
+ "hardcore hip-hop": "Hip-Hop"
+ "jazzy hip-hop": "Hip-Hop"
+ "ragga hip-hop": "Hip-Hop"
+ "funk / soul": "Funk"
+ "funk/soul": "Funk"
+ "folk, world, & country": "Folk"
+ "folk world & country": "Folk"
+ "rap": "Hip-Hop"
+ "hip-hop": "Hip-Hop"
+ "hiphop": "Hip-Hop"
+ "rap/hip-hop": "Hip-Hop"
+ "gangsta rap": "Hip-Hop"
+ "southern hip hop": "Hip-Hop"
+ "trap music": "Trap"
+ "phonk music": "Phonk"
+ "electronic music": "Electronic"
+ "electronica": "Electronic"
+ "electro": "Electronic"
+ "edm": "Electronic"
+ "electronic dance music": "Electronic"
+ "rnb": "R&B"
+ "r and b": "R&B"
+ "rhythm and blues": "R&B"
+ "dnb": "Drum and Bass"
+ "drum & bass": "Drum and Bass"
+ "d&b": "Drum and Bass"
+ "synth pop": "Synth-Pop"
+ "synthpop": "Synth-Pop"
+ "indie": "Indie Rock"
+ "alternative": "Alternative Rock"
+ "alt rock": "Alternative Rock"
+ "alt-rock": "Alternative Rock"
+ "lofi hip hop": "Lo-Fi Hip-Hop"
+ "lo fi hip hop": "Lo-Fi Hip-Hop"
+ "lo-fi hip-hop": "Lo-Fi Hip-Hop"
+ "lofi": "Lo-Fi Hip-Hop"
+ "lo fi": "Lo-Fi Hip-Hop"
+ "lo-fi": "Lo-Fi Hip-Hop"
+ "post punk": "Post-Punk"
+ "neo soul": "Neo-Soul"
+ "jazz fusion": "Jazz Fusion"
+ "free jazz": "Free Jazz"
+ "deep house": "Deep House"
+ "tech house": "Tech House"
+ "country music": "Country"
+ "alt country": "Alt-Country"
+ "indie folk": "Indie Folk"
+ "indie pop": "Indie Pop"
+ "dream pop": "Dream Pop"
+ "art pop": "Art Pop"
+ "conscious rap": "Conscious Hip-Hop"
+ "conscious hiphop": "Conscious Hip-Hop"
+ "g funk": "G-Funk"
+ "uk drill": "UK Drill"
+ "brooklyn drill": "Brooklyn Drill"
+ "mumble rap": "Mumble Rap"
+ "soundcloud rap": "SoundCloud Rap"
diff --git a/config/startup.sh b/config/startup.sh
new file mode 100644
index 00000000..96cacae0
--- /dev/null
+++ b/config/startup.sh
@@ -0,0 +1,3 @@
+#!/bin/sh
+# Copy custom beets plugins into the container's beetsplug directory
+cp /config/beets/beetsplug/*.py /usr/local/lib/python3.11/site-packages/beetsplug/
diff --git a/docker-compose.yaml b/docker-compose.yaml
new file mode 100644
index 00000000..401e4d4a
--- /dev/null
+++ b/docker-compose.yaml
@@ -0,0 +1,16 @@
+services:
+ beets-flask:
+ image: ghcr.io/matthieudecamy/beets-flask:latest
+ container_name: shlagi-tagger
+ restart: unless-stopped
+ ports:
+ - "5006:5001"
+ environment:
+ TZ: "Europe/Paris"
+ USER_ID: 1026
+ GROUP_ID: 100
+ volumes:
+ - /volumeUSB1/usbshare/beets-flask/config:/config
+ # for music folders, paths match inside and out of container!
+ - /volume1/docker/Music/MusicToTag:/volume1/docker/Music/MusicToTag
+ - /volume1/docker/Music/Music:/volume1/docker/Music/Music
diff --git a/docker/Dockerfile b/docker/Dockerfile
index d6ac7a0a..23b9c5b4 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -1,165 +1,165 @@
-FROM python:3.11-alpine3.20 AS base
-
-FROM base AS deps
-
-
-RUN addgroup -g 1000 beetle && \
- adduser -D -u 1000 -G beetle beetle
-
-ENV HOSTNAME="beets-container"
-ENV EDITOR="vi"
-# need to set some cli editor so `beet edit` works, vi comes with alpine
-
-# map beets directory and our configs to /config
-RUN mkdir -p /config/beets
-RUN mkdir -p /config/beets-flask
-RUN mkdir -p /logs
-RUN chown -R beetle:beetle /config
-RUN chown -R beetle:beetle /logs
-ENV BEETSDIR="/config/beets"
-ENV BEETSFLASKDIR="/config/beets-flask"
-ENV BEETSFLASKLOG="/logs/beets-flask.log"
-
-# our default folders they should not be used in production
-RUN mkdir -p /music/inbox
-RUN mkdir -p /music/imported
-RUN chown -R beetle:beetle /music
-
-# dependencies
-# RUN --mount=type=cache,target=/var/cache/apk \
-RUN apk update
-RUN --mount=type=cache,target=/var/cache/apk \
- apk add \
- imagemagick \
- redis \
- bash \
- tmux \
- shadow \
- git \
- ffmpeg
-
-
-# Install backend dependencies
-
-# Prevent __pycache__ directories
-ENV PYTHONUNBUFFERED=1 \
- PYTHONDONTWRITEBYTECODE=1
-# avoid creating a venv with uv, use system python
-ENV UV_PROJECT_ENVIRONMENT="/usr/local/"
-
-RUN --mount=type=cache,target=/root/.cache/pip \
- pip install --no-cache-dir uv
-
-
-WORKDIR /repo/backend
-COPY ./backend/pyproject.toml /repo/backend/
-COPY ./README.md /repo/
-
-RUN uv sync --no-install-project
-
-# Install our package (backend)
-COPY ./backend/beets_flask/ /repo/backend/beets_flask/
-COPY ./backend/generate_types.py /repo/backend/
-COPY ./backend/launch_redis_workers.py /repo/backend/
-COPY ./backend/launch_watchdog_worker.py /repo/backend/
-COPY ./backend/launch_db_init.py /repo/backend/
-
-RUN uv sync
-
-# Extract version from pyproject.toml
-RUN mkdir -p /version
-RUN python -c "import tomllib; print(tomllib.load(open('/repo/backend/pyproject.toml', 'rb'))['project']['version'])" > /version/backend.txt
-
-# ------------------------------------------------------------------------------------ #
-# Development #
-# ------------------------------------------------------------------------------------ #
-
-FROM deps AS dev
-
-RUN --mount=type=cache,target=/var/cache/apk \
- apk add \
- npm
-
-RUN npm install -g pnpm
-RUN pnpm config set store-dir /repo/frontend/.pnpm-store
-
-# Copy the lock files and install dependencies
-WORKDIR /repo
-COPY ./frontend/package.json /repo/frontend/
-COPY ./frontend/pnpm-lock.yaml /repo/frontend/
-
-WORKDIR /repo/frontend
-# RUN pnpm i
-
-# Extract version from package.json
-RUN mkdir -p /version
-RUN python -c "import json; print(json.load(open('/repo/frontend/package.json'))['version'])" \
- > /version/frontend.txt
-
-ENV IB_SERVER_CONFIG="dev_docker"
-
-# relies on mounting this volume
-WORKDIR /repo
-USER root
-ENTRYPOINT [ \
- "/bin/sh", "-c", \
- "./docker/entrypoints/entrypoint_fix_permissions.sh && \
- su beetle -c ./docker/entrypoints/entrypoint_dev.sh" \
- ]
-
-# ------------------------------------------------------------------------------------ #
-# Build #
-# ------------------------------------------------------------------------------------ #
-
-FROM deps AS build
-# Build frontend files
-
-RUN --mount=type=cache,target=/var/cache/apk \
- apk add \
- npm
-
-RUN npm install -g pnpm
-RUN pnpm config set store-dir /repo/frontend/.pnpm-store
-
-WORKDIR /repo
-COPY ./frontend ./frontend/
-RUN chown -R beetle:beetle /repo
-
-# Extract version from package.json
-RUN mkdir -p /version
-RUN python -c "import json; print(json.load(open('/repo/frontend/package.json'))['version'])" \
- > /version/frontend.txt
-
-USER beetle
-WORKDIR /repo/frontend
-# RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
-RUN pnpm install
-RUN pnpm run build
-
-
-
-
-# ------------------------------------------------------------------------------------ #
-# Production #
-# ------------------------------------------------------------------------------------ #
-
-FROM deps AS prod
-
-ENV IB_SERVER_CONFIG="prod"
-
-WORKDIR /repo
-COPY --from=build /repo/frontend/dist /repo/frontend/dist
-COPY --from=build /version /version
-COPY docker/entrypoints .
-RUN chown -R beetle:beetle /repo
-
-USER root
-
-ENTRYPOINT [ \
- "/bin/sh", "-c", \
- "/repo/entrypoint_fix_permissions.sh && \
- /repo/entrypoint_user_scripts.sh && \
- su beetle -c /repo/entrypoint.sh" \
- ]
-
-
+FROM python:3.11-alpine3.21 AS base
+
+FROM base AS deps
+
+
+RUN addgroup -g 1000 beetle && \
+ adduser -D -u 1000 -G beetle beetle
+
+ENV HOSTNAME="beets-container"
+ENV EDITOR="vi"
+# need to set some cli editor so `beet edit` works, vi comes with alpine
+
+# map beets directory and our configs to /config
+RUN mkdir -p /config/beets
+RUN mkdir -p /config/beets-flask
+RUN mkdir -p /logs
+RUN chown -R beetle:beetle /config
+RUN chown -R beetle:beetle /logs
+ENV BEETSDIR="/config/beets"
+ENV BEETSFLASKDIR="/config/beets-flask"
+ENV BEETSFLASKLOG="/logs/beets-flask.log"
+
+# our default folders they should not be used in production
+RUN mkdir -p /music/inbox
+RUN mkdir -p /music/imported
+RUN chown -R beetle:beetle /music
+
+# dependencies
+# RUN --mount=type=cache,target=/var/cache/apk \
+RUN apk update
+RUN --mount=type=cache,target=/var/cache/apk \
+ apk add \
+ imagemagick \
+ redis \
+ bash \
+ tmux \
+ shadow \
+ git \
+ ffmpeg
+
+
+# Install backend dependencies
+
+# Prevent __pycache__ directories
+ENV PYTHONUNBUFFERED=1 \
+ PYTHONDONTWRITEBYTECODE=1
+# avoid creating a venv with uv, use system python
+ENV UV_PROJECT_ENVIRONMENT="/usr/local/"
+
+RUN --mount=type=cache,target=/root/.cache/pip \
+ pip install --no-cache-dir uv
+
+
+WORKDIR /repo/backend
+COPY ./backend/pyproject.toml /repo/backend/
+COPY ./README.md /repo/
+
+RUN uv sync --no-install-project
+
+# Install our package (backend)
+COPY ./backend/beets_flask/ /repo/backend/beets_flask/
+COPY ./backend/generate_types.py /repo/backend/
+COPY ./backend/launch_redis_workers.py /repo/backend/
+COPY ./backend/launch_watchdog_worker.py /repo/backend/
+COPY ./backend/launch_db_init.py /repo/backend/
+
+RUN uv sync
+
+# Extract version from pyproject.toml
+RUN mkdir -p /version
+RUN python -c "import tomllib; print(tomllib.load(open('/repo/backend/pyproject.toml', 'rb'))['project']['version'])" > /version/backend.txt
+
+# ------------------------------------------------------------------------------------ #
+# Development #
+# ------------------------------------------------------------------------------------ #
+
+FROM deps AS dev
+
+RUN --mount=type=cache,target=/var/cache/apk \
+ apk add \
+ npm
+
+RUN npm install -g pnpm
+RUN pnpm config set store-dir /repo/frontend/.pnpm-store
+
+# Copy the lock files and install dependencies
+WORKDIR /repo
+COPY ./frontend/package.json /repo/frontend/
+COPY ./frontend/pnpm-lock.yaml /repo/frontend/
+
+WORKDIR /repo/frontend
+# RUN pnpm i
+
+# Extract version from package.json
+RUN mkdir -p /version
+RUN python -c "import json; print(json.load(open('/repo/frontend/package.json'))['version'])" \
+ > /version/frontend.txt
+
+ENV IB_SERVER_CONFIG="dev_docker"
+
+# relies on mounting this volume
+WORKDIR /repo
+USER root
+ENTRYPOINT [ \
+ "/bin/sh", "-c", \
+ "./docker/entrypoints/entrypoint_fix_permissions.sh && \
+ su beetle -c ./docker/entrypoints/entrypoint_dev.sh" \
+ ]
+
+# ------------------------------------------------------------------------------------ #
+# Build #
+# ------------------------------------------------------------------------------------ #
+
+FROM deps AS build
+# Build frontend files
+
+RUN --mount=type=cache,target=/var/cache/apk \
+ apk add \
+ npm
+
+RUN npm install -g pnpm
+RUN pnpm config set store-dir /repo/frontend/.pnpm-store
+
+WORKDIR /repo
+COPY ./frontend ./frontend/
+RUN chown -R beetle:beetle /repo
+
+# Extract version from package.json
+RUN mkdir -p /version
+RUN python -c "import json; print(json.load(open('/repo/frontend/package.json'))['version'])" \
+ > /version/frontend.txt
+
+USER beetle
+WORKDIR /repo/frontend
+# RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
+RUN pnpm install
+RUN pnpm run build
+
+
+
+
+# ------------------------------------------------------------------------------------ #
+# Production #
+# ------------------------------------------------------------------------------------ #
+
+FROM deps AS prod
+
+ENV IB_SERVER_CONFIG="prod"
+
+WORKDIR /repo
+COPY --from=build /repo/frontend/dist /repo/frontend/dist
+COPY --from=build /version /version
+COPY docker/entrypoints .
+RUN chown -R beetle:beetle /repo
+
+USER root
+
+ENTRYPOINT [ \
+ "/bin/sh", "-c", \
+ "/repo/entrypoint_fix_permissions.sh && \
+ /repo/entrypoint_user_scripts.sh && \
+ su beetle -c /repo/entrypoint.sh" \
+ ]
+
+
diff --git a/docker/docker-compose.dev.yaml b/docker/docker-compose.dev.yaml
index 59731f5d..e3ac2910 100644
--- a/docker/docker-compose.dev.yaml
+++ b/docker/docker-compose.dev.yaml
@@ -1,36 +1,36 @@
-services:
- beets-flask:
- container_name: beets-flask
- hostname: beets-container
- build:
- context: ..
- dockerfile: docker/Dockerfile
- target: dev
- image: beets-flask
- restart: unless-stopped
- ports:
- - "5001:5001" # for beets-flask
- - "5173:5173" # for vite dev server
- environment:
- # 502 is default on macos, 1000 on linux
- USER_ID: 1000
- GROUP_ID: 1000
- LOG_LEVEL_BEETSFLASK: DEBUG # this is used for our own logs. (set beets level via the config)
- LOG_LEVEL_OTHERS: WARNING # this is passed python logging basic config (all other modules)
- volumes:
- # if you want to use the same beets-library inside and out:
- # YOU NEED TO MAP YOUR MUSIC FOLDER IN THE CONTAINER TO THE SAME PATH AS OUTSIDE
- # and make sure the config used in the container has the right path!
- - ../local/music/:/music/
-
- # we put the beets and our beets-flask config into the /config directory
- # create these folders before starting the container! otherwise you might
- # get permission issues.
- - ../local/config/:/config
-
- # For debugging purposes, you can also mount the logs directory
- # for instance if you want to report an issue
- - ../local/logs/:/logs
-
- # for development. (disable if target is `prod`)
- - ../:/repo/
+services:
+ beets-flask:
+ container_name: beets-flask
+ hostname: beets-container
+ build:
+ context: ..
+ dockerfile: docker/Dockerfile
+ target: dev
+ image: beets-flask
+ restart: unless-stopped
+ ports:
+ - "5001:5001" # for beets-flask
+ - "5173:5173" # for vite dev server
+ environment:
+ # 502 is default on macos, 1000 on linux
+ USER_ID: 1000
+ GROUP_ID: 1000
+ LOG_LEVEL_BEETSFLASK: DEBUG # this is used for our own logs. (set beets level via the config)
+ LOG_LEVEL_OTHERS: WARNING # this is passed python logging basic config (all other modules)
+ volumes:
+ # if you want to use the same beets-library inside and out:
+ # YOU NEED TO MAP YOUR MUSIC FOLDER IN THE CONTAINER TO THE SAME PATH AS OUTSIDE
+ # and make sure the config used in the container has the right path!
+ - ../local/music/:/music/
+
+ # we put the beets and our beets-flask config into the /config directory
+ # create these folders before starting the container! otherwise you might
+ # get permission issues.
+ - ../local/config/:/config
+
+ # For debugging purposes, you can also mount the logs directory
+ # for instance if you want to report an issue
+ - ../local/logs/:/logs
+
+ # for development. (disable if target is `prod`)
+ - ../:/repo/
diff --git a/docker/docker-compose.tests.yaml b/docker/docker-compose.tests.yaml
index 765ede09..0eddfab4 100644
--- a/docker/docker-compose.tests.yaml
+++ b/docker/docker-compose.tests.yaml
@@ -1,18 +1,18 @@
-services:
- beets-flask-tests:
- container_name: beets-flask-tests
- hostname: beets-container
- build:
- context: .
- dockerfile: Dockerfile
- target: test
- image: beets-flask-tests
- ports:
- - "5001:5001"
- - "5173:5173"
- environment:
- # 502 is default on macos, 1000 on linux
- USER_ID: 502
- GROUP_ID: 502
- volumes:
- - ./:/repo/
+services:
+ beets-flask-tests:
+ container_name: beets-flask-tests
+ hostname: beets-container
+ build:
+ context: .
+ dockerfile: Dockerfile
+ target: test
+ image: beets-flask-tests
+ ports:
+ - "5001:5001"
+ - "5173:5173"
+ environment:
+ # 502 is default on macos, 1000 on linux
+ USER_ID: 502
+ GROUP_ID: 502
+ volumes:
+ - ./:/repo/
diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml
deleted file mode 100644
index b663c805..00000000
--- a/docker/docker-compose.yaml
+++ /dev/null
@@ -1,22 +0,0 @@
-services:
- beets-flask:
- image: pspitzner/beets-flask:stable
- restart: unless-stopped
- ports:
- - "5001:5001"
- environment:
- # Change to your timezone
- TZ: "Europe/Berlin"
- # 502 is default on macos, 1000 on linux
- USER_ID: 1000
- GROUP_ID: 1000
- # Optional: Add extra groups to the beetle user for file permissions
- # Format: "group_name1:gid1,group_name2:gid2"
- # Example: EXTRA_GROUPS: "nas_shares:1001,media:1002"
- volumes:
- - /wherever/config/:/config
- # for music folders, match paths inside and out of container!
- - /music_path/inbox/:/music_path/inbox/
- - /music_path/clean/:/music_path/clean/
- # If you want to persist the logs, you can mount a logs directory
- # - /wherever/logs/:/logs
diff --git a/docker/entrypoints/entrypoint.sh b/docker/entrypoints/entrypoint.sh
old mode 100755
new mode 100644
diff --git a/docker/entrypoints/entrypoint_dev.sh b/docker/entrypoints/entrypoint_dev.sh
old mode 100755
new mode 100644
diff --git a/docker/entrypoints/entrypoint_fix_permissions.sh b/docker/entrypoints/entrypoint_fix_permissions.sh
old mode 100755
new mode 100644
diff --git a/docker/entrypoints/entrypoint_user_scripts.sh b/docker/entrypoints/entrypoint_user_scripts.sh
old mode 100755
new mode 100644
diff --git a/docs/.readthedocs.yaml b/docs/.readthedocs.yaml
index 155de2dc..6a171f16 100644
--- a/docs/.readthedocs.yaml
+++ b/docs/.readthedocs.yaml
@@ -1,34 +1,34 @@
-# .readthedocs.yaml
-# Read the Docs configuration file
-# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
-
-# Required
-version: 2
-
-# Set the OS, Python version and other tools you might need
-build:
- os: ubuntu-22.04
- tools:
- python: "3.11"
- # You can also specify other tool versions:
- # nodejs: "19"
- # rust: "1.64"
- # golang: "1.19"
-
-# Build documentation in the "docs/" directory with Sphinx
-sphinx:
- configuration: ./docs/conf.py
-# Optionally build your docs in additional formats such as PDF and ePub
-# formats:
-# - pdf
-# - epub
-
-# Optional but recommended, declare the Python requirements required
-# to build your documentation
-# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
-python:
- install:
- - method: pip
- path: ./backend
- extra_requirements:
- - docs
+# .readthedocs.yaml
+# Read the Docs configuration file
+# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
+
+# Required
+version: 2
+
+# Set the OS, Python version and other tools you might need
+build:
+ os: ubuntu-22.04
+ tools:
+ python: "3.11"
+ # You can also specify other tool versions:
+ # nodejs: "19"
+ # rust: "1.64"
+ # golang: "1.19"
+
+# Build documentation in the "docs/" directory with Sphinx
+sphinx:
+ configuration: ./docs/conf.py
+# Optionally build your docs in additional formats such as PDF and ePub
+# formats:
+# - pdf
+# - epub
+
+# Optional but recommended, declare the Python requirements required
+# to build your documentation
+# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
+python:
+ install:
+ - method: pip
+ path: ./backend
+ extra_requirements:
+ - docs
diff --git a/docs/Makefile b/docs/Makefile
index b432ee1a..27c57196 100644
--- a/docs/Makefile
+++ b/docs/Makefile
@@ -1,25 +1,25 @@
-# Minimal makefile for Sphinx documentation
-#
-
-# You can set these variables from the command line, and also
-# from the environment for the first two.
-SPHINXOPTS ?=
-SPHINXBUILD ?= sphinx-build
-SOURCEDIR = .
-BUILDDIR = build
-
-# Put it first so that "make" without argument is like "make help".
-help:
- @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
-
-.PHONY: help Makefile
-
-# Catch-all target: route all unknown targets to Sphinx using the new
-# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
-%: Makefile
- @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
-
-
-clean:
- rm -rf $(BUILDDIR)/*
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line, and also
+# from the environment for the first two.
+SPHINXOPTS ?=
+SPHINXBUILD ?= sphinx-build
+SOURCEDIR = .
+BUILDDIR = build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+ @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+ @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+
+clean:
+ rm -rf $(BUILDDIR)/*
rm -rf $(SOURCEDIR)/_autosummary/*
\ No newline at end of file
diff --git a/docs/_static/custom.css b/docs/_static/custom.css
index 0f824bc5..5d89f943 100644
--- a/docs/_static/custom.css
+++ b/docs/_static/custom.css
@@ -1,7 +1,7 @@
-.table-wrapper table {
- width: 100%;
-}
-
-.icon {
- vertical-align: middle;
-}
+.table-wrapper table {
+ width: 100%;
+}
+
+.icon {
+ vertical-align: middle;
+}
diff --git a/docs/_templates/base.html b/docs/_templates/base.html
index 78ae747b..d4efae37 100644
--- a/docs/_templates/base.html
+++ b/docs/_templates/base.html
@@ -1,28 +1,28 @@
-{% extends "furo/base.html" %}
-
-{%- block scripts %}
-
-{# Custom JS #}
-{%- block regular_scripts -%}
-{% for path in script_files -%}
-{{ js_tag(path) }}
-{% endfor -%}
-{%- endblock regular_scripts -%}
-
-{# Theme-related JavaScript code #}
-{%- block theme_scripts -%}
-{%- endblock -%}
-
-
-
-
-
+{% extends "furo/base.html" %}
+
+{%- block scripts %}
+
+{# Custom JS #}
+{%- block regular_scripts -%}
+{% for path in script_files -%}
+{{ js_tag(path) }}
+{% endfor -%}
+{%- endblock regular_scripts -%}
+
+{# Theme-related JavaScript code #}
+{%- block theme_scripts -%}
+{%- endblock -%}
+
+
+
+
+
{% endblock %}
\ No newline at end of file
diff --git a/docs/_templates/base.rst b/docs/_templates/base.rst
index b7556ebf..98417f5b 100644
--- a/docs/_templates/base.rst
+++ b/docs/_templates/base.rst
@@ -1,5 +1,5 @@
-{{ fullname | escape | underline}}
-
-.. currentmodule:: {{ module }}
-
-.. auto{{ objtype }}:: {{ objname }}
+{{ fullname | escape | underline}}
+
+.. currentmodule:: {{ module }}
+
+.. auto{{ objtype }}:: {{ objname }}
diff --git a/docs/_templates/class.rst b/docs/_templates/class.rst
index e5fe2a1a..1107d906 100644
--- a/docs/_templates/class.rst
+++ b/docs/_templates/class.rst
@@ -1,34 +1,34 @@
-{{ fullname | escape | underline}}
-
-.. currentmodule:: {{ module }}
-
-.. autoclass:: {{ objname }}
- :members:
- :inherited-members:
- :special-members: __init__
-
- {% block attributes %}
- {% if attributes %}
- .. rubric:: {{ _('Attributes') }}
-
- .. autosummary::
- {% for item in attributes %}
- ~{{ name }}.{{ item }}
- {%- endfor %}
- {% endif %}
- {% endblock %}
-
-
- {% block methods %}
-
- {% if methods %}
- .. rubric:: {{ _('Methods') }}
-
- .. autosummary::
- {% for item in methods %}
- ~{{ name }}.{{ item }}
- {%- endfor %}
- {% endif %}
- {% endblock %}
-
-
+{{ fullname | escape | underline}}
+
+.. currentmodule:: {{ module }}
+
+.. autoclass:: {{ objname }}
+ :members:
+ :inherited-members:
+ :special-members: __init__
+
+ {% block attributes %}
+ {% if attributes %}
+ .. rubric:: {{ _('Attributes') }}
+
+ .. autosummary::
+ {% for item in attributes %}
+ ~{{ name }}.{{ item }}
+ {%- endfor %}
+ {% endif %}
+ {% endblock %}
+
+
+ {% block methods %}
+
+ {% if methods %}
+ .. rubric:: {{ _('Methods') }}
+
+ .. autosummary::
+ {% for item in methods %}
+ ~{{ name }}.{{ item }}
+ {%- endfor %}
+ {% endif %}
+ {% endblock %}
+
+
diff --git a/docs/_templates/module.rst b/docs/_templates/module.rst
index f6be19ad..c0ce4b66 100644
--- a/docs/_templates/module.rst
+++ b/docs/_templates/module.rst
@@ -1,86 +1,86 @@
-{{ fullname | escape | underline}}
-
-.. automodule:: {{ fullname }}
- {% block attributes %}
- {%- if attributes %}
- .. rubric:: {{ _('Module Attributes') }}
-
- .. autosummary::
- {% for item in attributes %}
- {{ item }}
- {%- endfor %}
- {% endif %}
- {%- endblock %}
-
- {%- block functions %}
- {%- if functions %}
- .. rubric:: {{ _('Functions') }}
-
- .. autosummary::
- {% for item in functions %}
- {{ item }}
- {%- endfor %}
-
- {% endif %}
- {%- endblock %}
-
- {%- block classes %}
- {%- if classes %}
- .. rubric:: {{ _('Classes') }}
-
- .. autosummary::
- :toctree:
- :template: class.rst
- {% for item in classes %}
- {{ item }}
- {%- endfor %}
- {% endif %}
- {%- endblock %}
-
- {%- block exceptions %}
- {%- if exceptions %}
- .. rubric:: {{ _('Exceptions') }}
-
- .. autosummary::
- {% for item in exceptions %}
- {{ item }}
- {%- endfor %}
- {% endif %}
- {%- endblock %}
-
-
-{%- block modules %}
-{%- if modules %}
-.. rubric:: Modules
-
-.. autosummary::
- :toctree:
- :template: module.rst
- :recursive:
-{% for item in modules %}
- {{ item }}
-{%- endfor %}
-{% endif %}
-{%- endblock %}
-
-
-{%- if attributes %}
-.. rubric:: {{ _('Module Attributes') }}
-{% for item in attributes %}
-.. autodata:: {{ item }}
-{%- endfor %}
-{% endif %}
-
-{%- if functions %}
-.. rubric:: {{ _('Module Functions') }}
-{% for item in functions %}
-.. autofunction:: {{ item }}
-{%- endfor %}
-{% endif %}
-
-{%- if exceptions %}
-.. rubric:: {{ _('Module Exceptions') }}
-{% for item in exceptions %}
-.. autoexception:: {{ item }}
-{%- endfor %}
-{% endif %}
+{{ fullname | escape | underline}}
+
+.. automodule:: {{ fullname }}
+ {% block attributes %}
+ {%- if attributes %}
+ .. rubric:: {{ _('Module Attributes') }}
+
+ .. autosummary::
+ {% for item in attributes %}
+ {{ item }}
+ {%- endfor %}
+ {% endif %}
+ {%- endblock %}
+
+ {%- block functions %}
+ {%- if functions %}
+ .. rubric:: {{ _('Functions') }}
+
+ .. autosummary::
+ {% for item in functions %}
+ {{ item }}
+ {%- endfor %}
+
+ {% endif %}
+ {%- endblock %}
+
+ {%- block classes %}
+ {%- if classes %}
+ .. rubric:: {{ _('Classes') }}
+
+ .. autosummary::
+ :toctree:
+ :template: class.rst
+ {% for item in classes %}
+ {{ item }}
+ {%- endfor %}
+ {% endif %}
+ {%- endblock %}
+
+ {%- block exceptions %}
+ {%- if exceptions %}
+ .. rubric:: {{ _('Exceptions') }}
+
+ .. autosummary::
+ {% for item in exceptions %}
+ {{ item }}
+ {%- endfor %}
+ {% endif %}
+ {%- endblock %}
+
+
+{%- block modules %}
+{%- if modules %}
+.. rubric:: Modules
+
+.. autosummary::
+ :toctree:
+ :template: module.rst
+ :recursive:
+{% for item in modules %}
+ {{ item }}
+{%- endfor %}
+{% endif %}
+{%- endblock %}
+
+
+{%- if attributes %}
+.. rubric:: {{ _('Module Attributes') }}
+{% for item in attributes %}
+.. autodata:: {{ item }}
+{%- endfor %}
+{% endif %}
+
+{%- if functions %}
+.. rubric:: {{ _('Module Functions') }}
+{% for item in functions %}
+.. autofunction:: {{ item }}
+{%- endfor %}
+{% endif %}
+
+{%- if exceptions %}
+.. rubric:: {{ _('Module Exceptions') }}
+{% for item in exceptions %}
+.. autoexception:: {{ item }}
+{%- endfor %}
+{% endif %}
diff --git a/docs/_templates/page.html b/docs/_templates/page.html
index 2e3ef154..debdb3e9 100644
--- a/docs/_templates/page.html
+++ b/docs/_templates/page.html
@@ -1,95 +1,95 @@
-{% extends "furo/page.html" %}
-
-
-
-{% block footer %}
-
-
-
- {%- if show_copyright %}
-
- {%- if hasdoc('copyright') %}
- {% trans path=pathto('copyright'), copyright=copyright|e -%}
-
Copyright © {{ copyright }}
- {%- endtrans %}
- {%- else %}
- {% trans copyright=copyright|e -%}
- Copyright © {{ copyright }}
- {%- endtrans %}
- {%- endif %}
-
- {%- endif %}
- {%- if last_updated -%}
-
- {% trans last_updated=last_updated|e -%}
- Last updated on {{ last_updated }}
- {%- endtrans -%}
-
- {%- endif %}
-
-
- {% if theme_footer_icons or READTHEDOCS -%}
-
- {% if theme_footer_icons -%}
- {% for icon_dict in theme_footer_icons -%}
-
- {{- icon_dict.html -}}
-
- {% endfor %}
- {%- else -%}
- {#- Show Read the Docs project -#}
- {%- if READTHEDOCS and slug -%}
-
-
-
-
-
- {%- endif -%}
- {#- Show GitHub repository home -#}
- {%- if READTHEDOCS and display_github and github_user != "None" and github_repo != "None" -%}
-
-
-
-
-
-
- {%- endif -%}
- {%- endif %}
-
- {%- endif %}
-
-
+{% extends "furo/page.html" %}
+
+
+
+{% block footer %}
+
+
+
+ {%- if show_copyright %}
+
+ {%- if hasdoc('copyright') %}
+ {% trans path=pathto('copyright'), copyright=copyright|e -%}
+
Copyright © {{ copyright }}
+ {%- endtrans %}
+ {%- else %}
+ {% trans copyright=copyright|e -%}
+ Copyright © {{ copyright }}
+ {%- endtrans %}
+ {%- endif %}
+
+ {%- endif %}
+ {%- if last_updated -%}
+
+ {% trans last_updated=last_updated|e -%}
+ Last updated on {{ last_updated }}
+ {%- endtrans -%}
+
+ {%- endif %}
+
+
+ {% if theme_footer_icons or READTHEDOCS -%}
+
+ {% if theme_footer_icons -%}
+ {% for icon_dict in theme_footer_icons -%}
+
+ {{- icon_dict.html -}}
+
+ {% endfor %}
+ {%- else -%}
+ {#- Show Read the Docs project -#}
+ {%- if READTHEDOCS and slug -%}
+
+
+
+
+
+ {%- endif -%}
+ {#- Show GitHub repository home -#}
+ {%- if READTHEDOCS and display_github and github_user != "None" and github_repo != "None" -%}
+
+
+
+
+
+
+ {%- endif -%}
+ {%- endif %}
+
+ {%- endif %}
+
+
{% endblock footer %}
\ No newline at end of file
diff --git a/docs/changelog.md b/docs/changelog.md
index 8261b353..ea42ed0c 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -1,2 +1,2 @@
-```{include} ../CHANGELOG.md
+```{include} ../CHANGELOG.md
```
\ No newline at end of file
diff --git a/docs/conf.py b/docs/conf.py
index 1d9fd499..aefbd4eb 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,80 +1,80 @@
-# Configuration file for the Sphinx documentation builder.
-#
-# For the full list of built-in configuration values, see the documentation:
-# https://www.sphinx-doc.org/en/master/usage/configuration.html
-
-
-# -- Project information -----------------------------------------------------
-# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
-
-project = "Beets-Flask"
-copyright = "2025, P. Spitzner & S. Mohr"
-author = "P. Spitzner & S. Mohr"
-
-master_doc = "index"
-language = "en"
-
-# -- General configuration ---------------------------------------------------
-# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
-
-
-templates_path = ["_templates"]
-exclude_patterns = []
-
-
-extensions = [
- "sphinx.ext.intersphinx",
- "sphinx.ext.autodoc",
- "sphinx.ext.autosummary",
- "sphinx_copybutton",
- "sphinx_inline_tabs",
- "sphinxcontrib.typer",
- "sphinx.ext.napoleon",
- # "myst_parser",
- "myst_nb",
-]
-autosummary_generate = True # Turn on sphinx.ext.autosummary
-intersphinx_mapping = {
- "python": ("https://docs.python.org/3", None),
- "jsonschema": ("https://python-jsonschema.readthedocs.io/en/stable", None),
-}
-nb_execution_mode = "off"
-myst_enable_extensions = [
- "colon_fence",
- "deflist",
-]
-
-
-# -- Options for HTML output -------------------------------------------------
-# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
-
-html_theme = "furo"
-html_static_path = ["_static"]
-html_theme_options = {
- # "light_logo": "favicon-128x128-light.png",
- # "dark_logo": "favicon-128x128-dark.png",
- "light_css_variables": {
- "color-brand-primary": "#2f3992",
- "color-brand-content": "#dee2e6",
- },
- "dark_css_variables": {
- "color-brand-primary": "#2f3992",
- "color-brand-content": "#dee2e6",
- },
- # Sources for editing
- "source_view_link": "https://github.com/pspitzner/beets-flask/blob/main/docs/{filename}",
- "footer_icons": [
- {
- "name": "GitHub",
- "url": "https://github.com/pspitzner/beets-flask",
- "html": """
-
- """,
- "class": "",
- },
- ],
-}
-html_css_files = [
- "custom.css",
-]
-html_logo = "../frontend/public/logo_flask.png"
+# Configuration file for the Sphinx documentation builder.
+#
+# For the full list of built-in configuration values, see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
+
+
+# -- Project information -----------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
+
+project = "Beets-Flask"
+copyright = "2025, P. Spitzner & S. Mohr"
+author = "P. Spitzner & S. Mohr"
+
+master_doc = "index"
+language = "en"
+
+# -- General configuration ---------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
+
+
+templates_path = ["_templates"]
+exclude_patterns = []
+
+
+extensions = [
+ "sphinx.ext.intersphinx",
+ "sphinx.ext.autodoc",
+ "sphinx.ext.autosummary",
+ "sphinx_copybutton",
+ "sphinx_inline_tabs",
+ "sphinxcontrib.typer",
+ "sphinx.ext.napoleon",
+ # "myst_parser",
+ "myst_nb",
+]
+autosummary_generate = True # Turn on sphinx.ext.autosummary
+intersphinx_mapping = {
+ "python": ("https://docs.python.org/3", None),
+ "jsonschema": ("https://python-jsonschema.readthedocs.io/en/stable", None),
+}
+nb_execution_mode = "off"
+myst_enable_extensions = [
+ "colon_fence",
+ "deflist",
+]
+
+
+# -- Options for HTML output -------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
+
+html_theme = "furo"
+html_static_path = ["_static"]
+html_theme_options = {
+ # "light_logo": "favicon-128x128-light.png",
+ # "dark_logo": "favicon-128x128-dark.png",
+ "light_css_variables": {
+ "color-brand-primary": "#2f3992",
+ "color-brand-content": "#dee2e6",
+ },
+ "dark_css_variables": {
+ "color-brand-primary": "#2f3992",
+ "color-brand-content": "#dee2e6",
+ },
+ # Sources for editing
+ "source_view_link": "https://github.com/pspitzner/beets-flask/blob/main/docs/{filename}",
+ "footer_icons": [
+ {
+ "name": "GitHub",
+ "url": "https://github.com/pspitzner/beets-flask",
+ "html": """
+
+ """,
+ "class": "",
+ },
+ ],
+}
+html_css_files = [
+ "custom.css",
+]
+html_logo = "../frontend/public/logo_flask.png"
diff --git a/docs/configuration.md b/docs/configuration.md
index 399855ba..a071db49 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -1,196 +1,196 @@
-# Configuration
-
-On first container launch, config files are automatically generated in the mounted `/config` folder.
-Configurations are read from:
-
-- `config/beets/config.yaml` (for the original cli tool that we wrap)
-- `config/beets-flask/config.yaml` (for frontend and container settings)
-
-```{warning}
-Configuration changes are only applied on container restart.
-Restart your container with `docker restart beets-flask` after changing a configuration option.
-```
-
-We extend the [default beets configuration](https://beets.readthedocs.io/en/stable/reference/config.html) with some additional options.
-You may use the following example configuration as a starting point.
-It contains all options that can be set in the `/config/beets-flask/config.yaml`.
-
-
-```{literalinclude} ../backend/beets_flask/config/config_bf_example.yaml
-:language: yaml
-```
-
-## Inboxes
-
-Allows you to configure the inboxes that are used to automatically import music files into your library.
-You may add multiple inboxes, each may have a different purpose.
-
-### `gui.inbox.folders`
-
-The `gui.inbox.folders` section allows you to define multiple inboxes, each with a name, path, and an `autotag` setting.
-The `autotag` setting determines how the files in the inbox are processed by beets-flask.
-
-- `"no"` you have to do everything manually.
-- `"preview"` fetch meta data from online sources, but don't import yet.
-- `"auto"` fetch meta data, and import if the match is good enough (based on threshold).
-- `"bootleg"` you are sure the meta data is fine, or it does not exist online.
- Drop files to import as-is in here, _but still create one subfolder for each
- import session you want to create_. (Beets acts on _folders_.
- Files directly inside the inbox wont trigger an imported)
-
-
-Note that the top label (i.e. Inbox1, Inbox2...) does not matter.
-
-```yaml
-gui:
- inbox:
- folders:
- Inbox1:
- name: "Dummy inbox"
- path: "/music/dummy"
- autotag: no
- # do not automatically trigger tagging and do not automatically import
-
- Inbox2:
- name: "An Inbox that only generates the previews"
- path: "/music/inbox_preview"
- autotag: "preview"
- # trigger tag but do not import, recommended for most control
-
- Inbox3:
- name: "Auto Inbox"
- path: "/music/inbox_auto"
- autotag: "auto"
- # trigger tag and import if a good match is found based on `auto_threshold`
-
- auto_threshold: null
- # if set to null, uses the value in beets config (match.strong_rec_thresh)
- # define the distance from a perfect match, i.e. set to 0.1 to import
- # matches with 90% similarity or better.
-
- Inbox4:
- name: "Paul's Bootlegs"
- path: "/music/inbox_bootlegs"
- autotag: "bootleg"
- # Import as-is using the meta data of files, and group albums
- # using the metadata, even if they are in the same folder
- # Effectively `beet import ... --group-albums -A`
-```
-
----
-
-### `gui.inbox.debounce_before_autotag`
-Specify the number of seconds to wait after the last filesystem event before starting the autotagging process.
-Applies to _all_ inboxes.
-For example, when adding files one by one, the timer gets reset after each file.
-Tagging only starts after no new files have been added for the specified time.
-Increase this to avoid integrity warnings when files are added slowly.
-The default value is `30` seconds.
-
----
-
-### `gui.inbox.ignore`
-Specifies a list of file patterns to ignore when scanning the inbox folders.
-This is useful to exclude temporary files or other unwanted files from being shown in the inbox.
-
-If not set, this will default to the [`ignore`](https://docs.beets.io/en/stable/reference/config.html#ignore) config from the `beets/config.yaml` file.
-
-To show all files in the inbox (independent of which files beets will copy) set this to an empty list `[]`.
-
-```yaml
-# beets-flask/config.yaml
-gui:
- inbox:
- ignore:
- # Usually you want to keep these in place.
- # When customizing, we _do not_ copy beets defaults over.
- - ".*"
- - "*~"
- - "System Volume Information"
- - "lost+found"
- # also exclude some common resource files on Synology NAS
- - "@eaDir"
- - "@SynoEAStream"
-
-# default in beets config
-ignore:
- - ".*"
- - "*~"
- - "System Volume Information"
- - "lost+found"
-
-```
-
-## Library
-
-The `gui.library` section contains options for the library view in the web interface.
-It allows you to configure how the library is displayed and how we interact with the beets library.
-
-### `gui.library.artist_separators`
-
-A list of characters that are used to split artist names in the library view.
-This is mainly used to handle artist searches and filtering.
-If you don't want this feature, you can set it to an empty list `[]`.
-The default is `[";", ",", "&"]`.
-
-## Terminal
-
-### `gui.terminal.start_path`
-Specifies the path that is used when starting the terminal in the web interface.
-This is useful if you want to start the terminal in a specific directory, such as your music library.
-The default value is `/music/inbox`.
-You should change this if you have a different inbox path!
-
-
-## Other options
-
-### `gui.num_preview_workers`
-Specifies the number of worker threads that are used to generate previews for the inboxes.
-This is useful to speed up the preview generation process, especially when you have a large number of items in your inboxes.
-The default value is `4`.
-
-```{note}
-You can use multiple workers to fetch candidates before importing (previewing).
-However, the import itself is always done sequentially.
-This is to ensure that the import process is not interrupted by other operations.
-```
-
-## Docker Environment Variables
-
-These environment variables are set in the `docker-compose.yaml` file and control the container's behavior.
-
-### `USER_ID` and `GROUP_ID`
-
-The `USER_ID` and `GROUP_ID` environment variables are used to set the UID and GID of the `beetle` user inside the container. This is useful to match the user and group IDs of the host system. The default value is `1000` for both.
-
-```yaml
-environment:
- USER_ID: 1000
- GROUP_ID: 1000
-```
-
-### `EXTRA_GROUPS`
-
-The `EXTRA_GROUPS` environment variable allows you to add additional groups to the `beetle` user. This is useful when you need the container to have access to files owned by different groups on the host system.
-
-The format is a comma-separated list of `group_name:gid` pairs:
-
-```yaml
-environment:
- EXTRA_GROUPS: "nas_shares:1001,media:1002"
-```
-
-This is particularly useful in scenarios where:
-- Files in the inbox are created by external services running as different users/groups
-- You're using ACL-based permissions with specific group access
-- You're running in environments like LXC/Proxmox with mapped group IDs
-- You need the container to manage files from network shares with specific group ownership
-
-Example: If your download client (e.g., slskd, transmission) creates files with group ownership `nas_shares` (gid 1001), you can add that group to the beetle user:
-
-```yaml
-environment:
- EXTRA_GROUPS: "nas_shares:1001"
-```
-
-This will allow the beets-flask container to delete and manage those files via the web UI.
+# Configuration
+
+On first container launch, config files are automatically generated in the mounted `/config` folder.
+Configurations are read from:
+
+- `config/beets/config.yaml` (for the original cli tool that we wrap)
+- `config/beets-flask/config.yaml` (for frontend and container settings)
+
+```{warning}
+Configuration changes are only applied on container restart.
+Restart your container with `docker restart beets-flask` after changing a configuration option.
+```
+
+We extend the [default beets configuration](https://beets.readthedocs.io/en/stable/reference/config.html) with some additional options.
+You may use the following example configuration as a starting point.
+It contains all options that can be set in the `/config/beets-flask/config.yaml`.
+
+
+```{literalinclude} ../backend/beets_flask/config/config_bf_example.yaml
+:language: yaml
+```
+
+## Inboxes
+
+Allows you to configure the inboxes that are used to automatically import music files into your library.
+You may add multiple inboxes, each may have a different purpose.
+
+### `gui.inbox.folders`
+
+The `gui.inbox.folders` section allows you to define multiple inboxes, each with a name, path, and an `autotag` setting.
+The `autotag` setting determines how the files in the inbox are processed by beets-flask.
+
+- `"no"` you have to do everything manually.
+- `"preview"` fetch meta data from online sources, but don't import yet.
+- `"auto"` fetch meta data, and import if the match is good enough (based on threshold).
+- `"bootleg"` you are sure the meta data is fine, or it does not exist online.
+ Drop files to import as-is in here, _but still create one subfolder for each
+ import session you want to create_. (Beets acts on _folders_.
+ Files directly inside the inbox wont trigger an imported)
+
+
+Note that the top label (i.e. Inbox1, Inbox2...) does not matter.
+
+```yaml
+gui:
+ inbox:
+ folders:
+ Inbox1:
+ name: "Dummy inbox"
+ path: "/music/dummy"
+ autotag: no
+ # do not automatically trigger tagging and do not automatically import
+
+ Inbox2:
+ name: "An Inbox that only generates the previews"
+ path: "/music/inbox_preview"
+ autotag: "preview"
+ # trigger tag but do not import, recommended for most control
+
+ Inbox3:
+ name: "Auto Inbox"
+ path: "/music/inbox_auto"
+ autotag: "auto"
+ # trigger tag and import if a good match is found based on `auto_threshold`
+
+ auto_threshold: null
+ # if set to null, uses the value in beets config (match.strong_rec_thresh)
+ # define the distance from a perfect match, i.e. set to 0.1 to import
+ # matches with 90% similarity or better.
+
+ Inbox4:
+ name: "Paul's Bootlegs"
+ path: "/music/inbox_bootlegs"
+ autotag: "bootleg"
+ # Import as-is using the meta data of files, and group albums
+ # using the metadata, even if they are in the same folder
+ # Effectively `beet import ... --group-albums -A`
+```
+
+---
+
+### `gui.inbox.debounce_before_autotag`
+Specify the number of seconds to wait after the last filesystem event before starting the autotagging process.
+Applies to _all_ inboxes.
+For example, when adding files one by one, the timer gets reset after each file.
+Tagging only starts after no new files have been added for the specified time.
+Increase this to avoid integrity warnings when files are added slowly.
+The default value is `30` seconds.
+
+---
+
+### `gui.inbox.ignore`
+Specifies a list of file patterns to ignore when scanning the inbox folders.
+This is useful to exclude temporary files or other unwanted files from being shown in the inbox.
+
+If not set, this will default to the [`ignore`](https://docs.beets.io/en/stable/reference/config.html#ignore) config from the `beets/config.yaml` file.
+
+To show all files in the inbox (independent of which files beets will copy) set this to an empty list `[]`.
+
+```yaml
+# beets-flask/config.yaml
+gui:
+ inbox:
+ ignore:
+ # Usually you want to keep these in place.
+ # When customizing, we _do not_ copy beets defaults over.
+ - ".*"
+ - "*~"
+ - "System Volume Information"
+ - "lost+found"
+ # also exclude some common resource files on Synology NAS
+ - "@eaDir"
+ - "@SynoEAStream"
+
+# default in beets config
+ignore:
+ - ".*"
+ - "*~"
+ - "System Volume Information"
+ - "lost+found"
+
+```
+
+## Library
+
+The `gui.library` section contains options for the library view in the web interface.
+It allows you to configure how the library is displayed and how we interact with the beets library.
+
+### `gui.library.artist_separators`
+
+A list of characters that are used to split artist names in the library view.
+This is mainly used to handle artist searches and filtering.
+If you don't want this feature, you can set it to an empty list `[]`.
+The default is `[";", ",", "&"]`.
+
+## Terminal
+
+### `gui.terminal.start_path`
+Specifies the path that is used when starting the terminal in the web interface.
+This is useful if you want to start the terminal in a specific directory, such as your music library.
+The default value is `/music/inbox`.
+You should change this if you have a different inbox path!
+
+
+## Other options
+
+### `gui.num_preview_workers`
+Specifies the number of worker threads that are used to generate previews for the inboxes.
+This is useful to speed up the preview generation process, especially when you have a large number of items in your inboxes.
+The default value is `4`.
+
+```{note}
+You can use multiple workers to fetch candidates before importing (previewing).
+However, the import itself is always done sequentially.
+This is to ensure that the import process is not interrupted by other operations.
+```
+
+## Docker Environment Variables
+
+These environment variables are set in the `docker-compose.yaml` file and control the container's behavior.
+
+### `USER_ID` and `GROUP_ID`
+
+The `USER_ID` and `GROUP_ID` environment variables are used to set the UID and GID of the `beetle` user inside the container. This is useful to match the user and group IDs of the host system. The default value is `1000` for both.
+
+```yaml
+environment:
+ USER_ID: 1000
+ GROUP_ID: 1000
+```
+
+### `EXTRA_GROUPS`
+
+The `EXTRA_GROUPS` environment variable allows you to add additional groups to the `beetle` user. This is useful when you need the container to have access to files owned by different groups on the host system.
+
+The format is a comma-separated list of `group_name:gid` pairs:
+
+```yaml
+environment:
+ EXTRA_GROUPS: "nas_shares:1001,media:1002"
+```
+
+This is particularly useful in scenarios where:
+- Files in the inbox are created by external services running as different users/groups
+- You're using ACL-based permissions with specific group access
+- You're running in environments like LXC/Proxmox with mapped group IDs
+- You need the container to manage files from network shares with specific group ownership
+
+Example: If your download client (e.g., slskd, transmission) creates files with group ownership `nas_shares` (gid 1001), you can add that group to the beetle user:
+
+```yaml
+environment:
+ EXTRA_GROUPS: "nas_shares:1001"
+```
+
+This will allow the beets-flask container to delete and manage those files via the web UI.
diff --git a/docs/develop/contribution.md b/docs/develop/contribution.md
index d69b7e12..5a762c54 100644
--- a/docs/develop/contribution.md
+++ b/docs/develop/contribution.md
@@ -1,130 +1,130 @@
-# Contributing
-
-We are always happy to see new contributors! If small or large, every contribution is welcome. Please follow these guidelines to ensure a smooth contribution process.
-
-## Prerequisites
-
-Some technical knowledge for the following tools is required to get started with the project. If you are not familiar with them, please check out the documentation for each tool and make sure you have them installed.
-
-- [Docker](https://docs.docker.com/get-started/)
-- [Docker Compose](https://docs.docker.com/compose/)
-- [Python](https://www.python.org/downloads/) 3.10 or higher
-- [Node.js](https://nodejs.org/en/download/) 18 or higher
-- [git](https://git-scm.com/downloads)
-- [pnpm](https://pnpm.io/installation) (or any other package manager)
-
-## Setting Up the Development Environment
-
-1. **Clone the repository:**
-
-```bash
-git clone https://github.com/pSpitzner/beets-flask
-cd beets-flask
-```
-
-2.1 **Install the dependencies (backend):**
-We recommend using a virtual environment to manage the dependencies.
-
-```bash
-cd backend
-pip install -e .[dev]
-```
-
-2.2 **Install the dependencies (frontend):**
-We use [pnpm](https://pnpm.io/) to manage the frontend dependencies. You may use any other package manager. On macOS you might want to run this command inside the container ([see here](resources/macos)).
-
-```bash
-cd frontend
-pnpm install --frozen-lockfile
-```
-
-3. **Run the application in dev mode:**
- Check the docker compose file and edit if necessary.
-
-```bash
-cd ../
-# We recommend to create a copy of the docker compose file
-cp ./docker/docker-compose.dev.yaml ./docker/docker-compose.dev-local.yaml
-# Run the application after editing the docker compose file
-docker compose -f ./docker/docker-compose.dev-local.yaml up --build
-```
-
-## Install pre-commit hooks
-
-We automatically check for code style and formatting issues using pre-commit hooks. To install the hooks, run the following command (optional):
-
-```bash
-pip install pre-commit
-pre-commit install
-```
-
-## Before Submitting a Pull Request
-
-Run [Ruff](https://docs.astral.sh/ruff/) manually or use the pre-commit hooks to check for any issues. Additionally, run the tests to ensure that your changes do not break any existing functionality.
-
-```bash
-cd backend
-# Run Ruff manually
-ruff check
-# Run the tests
-pytest
-```
-
-Run [eslint](https://eslint.org/) manually or use the pre-commit hooks to check for any issues. Additionally, run the tests to ensure that your changes do not break any existing functionality.
-
-```bash
-cd frontend
-# Run eslint manually
-pnpm lint
-# Check the types
-pnpm check-types
-```
-
-## Submitting a Pull Request
-
-Fork the repository and create a new branch for your changes. Feel free to follow [this guide](https://docs.github.com/en/get-started/quickstart/contributing-to-projects) for more information on how to create a pull request. Once you are done we will review your changes as soon as possible. Please be patient, as we are a small team and may not be able to review your changes immediately.
-
-## Example docker compose
-
-```bash
-git clone https://github.com/pSpitzner/beets-flask.git ./beets_flask_dev
-cd ./beets_flask_dev
-mkdir local
-```
-
-Tweak `docker/docker-compose.dev.yaml` to your needs. Important is to live mount your repo folder:
-
-```yaml
-services:
- beets-flask:
- container_name: beets-flask
- hostname: beets-container
- build:
- context: ..
- dockerfile: docker/Dockerfile
- target: dev
- image: beets-flask
- restart: unless-stopped
- ports:
- - "5001:5001"
- - "5173:5173"
- environment:
- USER_ID: 1000
- GROUP_ID: 1000
- LOG_LEVEL_BEETSFLASK: DEBUG
- LOG_LEVEL_OTHERS: WARNING
- volumes:
- - ../local/music/:/music/
- - ../local/config/:/config
- - ../:/repo/
-```
-
-After first launch you will need to install the frontend packages:
-
-```bash
-docker exec -it -u beetle beets-flask-dev bash
-cd /repo/frontend
-pnpm i
-```
-
-Check the viteserver at `localhost:5173`
+# Contributing
+
+We are always happy to see new contributors! If small or large, every contribution is welcome. Please follow these guidelines to ensure a smooth contribution process.
+
+## Prerequisites
+
+Some technical knowledge for the following tools is required to get started with the project. If you are not familiar with them, please check out the documentation for each tool and make sure you have them installed.
+
+- [Docker](https://docs.docker.com/get-started/)
+- [Docker Compose](https://docs.docker.com/compose/)
+- [Python](https://www.python.org/downloads/) 3.10 or higher
+- [Node.js](https://nodejs.org/en/download/) 18 or higher
+- [git](https://git-scm.com/downloads)
+- [pnpm](https://pnpm.io/installation) (or any other package manager)
+
+## Setting Up the Development Environment
+
+1. **Clone the repository:**
+
+```bash
+git clone https://github.com/pSpitzner/beets-flask
+cd beets-flask
+```
+
+2.1 **Install the dependencies (backend):**
+We recommend using a virtual environment to manage the dependencies.
+
+```bash
+cd backend
+pip install -e .[dev]
+```
+
+2.2 **Install the dependencies (frontend):**
+We use [pnpm](https://pnpm.io/) to manage the frontend dependencies. You may use any other package manager. On macOS you might want to run this command inside the container ([see here](resources/macos)).
+
+```bash
+cd frontend
+pnpm install --frozen-lockfile
+```
+
+3. **Run the application in dev mode:**
+ Check the docker compose file and edit if necessary.
+
+```bash
+cd ../
+# We recommend to create a copy of the docker compose file
+cp ./docker/docker-compose.dev.yaml ./docker/docker-compose.dev-local.yaml
+# Run the application after editing the docker compose file
+docker compose -f ./docker/docker-compose.dev-local.yaml up --build
+```
+
+## Install pre-commit hooks
+
+We automatically check for code style and formatting issues using pre-commit hooks. To install the hooks, run the following command (optional):
+
+```bash
+pip install pre-commit
+pre-commit install
+```
+
+## Before Submitting a Pull Request
+
+Run [Ruff](https://docs.astral.sh/ruff/) manually or use the pre-commit hooks to check for any issues. Additionally, run the tests to ensure that your changes do not break any existing functionality.
+
+```bash
+cd backend
+# Run Ruff manually
+ruff check
+# Run the tests
+pytest
+```
+
+Run [eslint](https://eslint.org/) manually or use the pre-commit hooks to check for any issues. Additionally, run the tests to ensure that your changes do not break any existing functionality.
+
+```bash
+cd frontend
+# Run eslint manually
+pnpm lint
+# Check the types
+pnpm check-types
+```
+
+## Submitting a Pull Request
+
+Fork the repository and create a new branch for your changes. Feel free to follow [this guide](https://docs.github.com/en/get-started/quickstart/contributing-to-projects) for more information on how to create a pull request. Once you are done we will review your changes as soon as possible. Please be patient, as we are a small team and may not be able to review your changes immediately.
+
+## Example docker compose
+
+```bash
+git clone https://github.com/pSpitzner/beets-flask.git ./beets_flask_dev
+cd ./beets_flask_dev
+mkdir local
+```
+
+Tweak `docker/docker-compose.dev.yaml` to your needs. Important is to live mount your repo folder:
+
+```yaml
+services:
+ beets-flask:
+ container_name: beets-flask
+ hostname: beets-container
+ build:
+ context: ..
+ dockerfile: docker/Dockerfile
+ target: dev
+ image: beets-flask
+ restart: unless-stopped
+ ports:
+ - "5001:5001"
+ - "5173:5173"
+ environment:
+ USER_ID: 1000
+ GROUP_ID: 1000
+ LOG_LEVEL_BEETSFLASK: DEBUG
+ LOG_LEVEL_OTHERS: WARNING
+ volumes:
+ - ../local/music/:/music/
+ - ../local/config/:/config
+ - ../:/repo/
+```
+
+After first launch you will need to install the frontend packages:
+
+```bash
+docker exec -it -u beetle beets-flask-dev bash
+cd /repo/frontend
+pnpm i
+```
+
+Check the viteserver at `localhost:5173`
diff --git a/docs/develop/resources/backend.md b/docs/develop/resources/backend.md
index 9403e3f1..4bbf57e2 100644
--- a/docs/develop/resources/backend.md
+++ b/docs/develop/resources/backend.md
@@ -1,27 +1,27 @@
-# Backend
-
-Beets-Flask provides a quart application with REST API for the beets music library manager and a library for interacting with beets.
-
-```{toctree}
-:hidden:
-
-./state_serialize
-```
-
-## Resumability of import
-
-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.
-
-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).
-
-## Environment variables
-
-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:
-
-```
-BEETSDIR="/config/beets"
-BEETSFLASKDIR="/config/beets-flask"
-BEETSFLASKLOG="/logs/beets-flask.log"
-```
-
-
+# Backend
+
+Beets-Flask provides a quart application with REST API for the beets music library manager and a library for interacting with beets.
+
+```{toctree}
+:hidden:
+
+./state_serialize
+```
+
+## Resumability of import
+
+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.
+
+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).
+
+## Environment variables
+
+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:
+
+```
+BEETSDIR="/config/beets"
+BEETSFLASKDIR="/config/beets-flask"
+BEETSFLASKLOG="/logs/beets-flask.log"
+```
+
+
diff --git a/docs/develop/resources/docker.md b/docs/develop/resources/docker.md
index 44a48871..03a128bb 100644
--- a/docs/develop/resources/docker.md
+++ b/docs/develop/resources/docker.md
@@ -1,10 +1,10 @@
-# Docker
-
-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.
-
-Redis-Caching seems to be very persistent and we have not figured out how to completely reset it without _rebuilding_ the container.
-Thus, currently, after code changes that run inside a redis worker `docker-compose up --build` is needed even when live-mounting the repo.
-
-## Entrypoints
-
-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.
+# Docker
+
+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.
+
+Redis-Caching seems to be very persistent and we have not figured out how to completely reset it without _rebuilding_ the container.
+Thus, currently, after code changes that run inside a redis worker `docker-compose up --build` is needed even when live-mounting the repo.
+
+## Entrypoints
+
+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.
diff --git a/docs/develop/resources/documentation.md b/docs/develop/resources/documentation.md
index 23012e06..37113e69 100644
--- a/docs/develop/resources/documentation.md
+++ b/docs/develop/resources/documentation.md
@@ -1,16 +1,16 @@
-# Documentation
-
-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.
-
-You may build the documentation locally with.
-
-```bash
-# Install the requirements
-cd backend
-pip install -e .[docs]
-# Build the documentation
-cd ../docs
-make html
-```
-
+# Documentation
+
+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.
+
+You may build the documentation locally with.
+
+```bash
+# Install the requirements
+cd backend
+pip install -e .[docs]
+# Build the documentation
+cd ../docs
+make html
+```
+
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.
\ No newline at end of file
diff --git a/docs/develop/resources/frontend.md b/docs/develop/resources/frontend.md
index 4200d428..b7d4bf1a 100644
--- a/docs/develop/resources/frontend.md
+++ b/docs/develop/resources/frontend.md
@@ -1,32 +1,32 @@
-# Frontend
-
-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.
-
-
-We use the follow `Tech Stack`:
-
-- [React](https://react.dev/)
-- [Vite](https://vitejs.dev/)
-- [Tanstack router](https://tanstack.com/router/latest)
-- [MUI Core](https://mui.com/material-ui/all-components/)
-- [Lucide icons](https://lucide.dev/icons/)
-
-
-## Package manager
-
-We use [pnpm](https://pnpm.io/) as package manager. If you have npm or yarn installed, you can install pnpm via corepack:
-```bash
-corepack enable pnpm
-corepack use pnpm@latest
-```
-alternatively follow the isntallation guide [here](https://pnpm.io/installation).
-
-## Scripts
-
-We expose some helper scripts in the package.json which you can run outside of the container. You can run them with `pnpm
-
+
+
+