├── .act └── .act-payload.json ├── .actrc ├── .dockerignore ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.md │ ├── config.yaml │ └── feature-request.md ├── dependabot.yaml ├── pull_request_template.md └── workflows │ ├── ci-test-lint.yaml │ ├── markdown-lint.yaml │ ├── pre-commit-updater.yaml │ ├── release.yaml │ └── typos.yaml ├── .gitignore ├── .markdownlint.yaml ├── .pre-commit-config.yaml ├── .tool-versions ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── SECURITY.md ├── _typos.toml ├── beetsplug ├── __init__.py ├── filetote.py ├── filetote_dataclasses.py ├── mapping_model.py └── py.typed ├── compose.yaml ├── example.config.yaml ├── poetry.lock ├── pyproject.toml ├── setup.py ├── tests ├── __init__.py ├── _common.py ├── _item_model.py ├── helper.py ├── rsrc │ ├── full.flac │ ├── full.mp3 │ └── full.wav ├── test_audible_m4b_files.py ├── test_cli_operation.py ├── test_exclude.py ├── test_filename.py ├── test_filesize_fixes.py ├── test_flatdirectory.py ├── test_manipulate_files.py ├── test_music_files.py ├── test_nesteddirectory.py ├── test_pairing.py ├── test_patterns.py ├── test_printignored.py ├── test_pruning.py ├── test_reimport.py ├── test_rename.py ├── test_rename_convert_plugin.py ├── test_rename_filetote_fields.py ├── test_rename_inline_plugin.py ├── test_rename_item_fields.py ├── test_rename_paths.py ├── test_version.py └── unit │ ├── __init__.py │ └── test_filetote_dataclasses.py └── typehints ├── beets ├── __init__.pyi ├── dbcore │ ├── __init__.py │ ├── db.pyi │ └── types.pyi ├── importer.pyi ├── library.pyi ├── logging.pyi ├── plugins.pyi ├── ui │ ├── __init__.pyi │ └── commands.pyi └── util │ ├── __init__.pyi │ └── functemplate.pyi ├── confuse ├── __init__.pyi └── templates.pyi ├── mediafile └── __init__.pyi └── reflink └── __init__.pyi /.act/.act-payload.json: -------------------------------------------------------------------------------- 1 | { 2 | "//comment": "Event payload for local GitHub Action testing. See: https://nektosact.com/usage/index.html#events", 3 | "act": true, 4 | "repository": { 5 | "full_name": "gtronset/beets-filetote" 6 | }, 7 | "release": { 8 | "tag_name": "v9.99.999" 9 | }, 10 | "push": { 11 | "ref": "refs/tags/v9.99.999" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.actrc: -------------------------------------------------------------------------------- 1 | -e .act/.act-payload.json 2 | --secret-file .act/.act-secrets 3 | --var-file .act/.act-variables 4 | --artifact-server-addr host.docker.internal 5 | --cache-server-addr host.docker.internal 6 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .venv/ 2 | __pycache__/ 3 | .pytest_cache/ 4 | .pypy_cache/ 5 | .tox/ 6 | dist/ 7 | build/ 8 | env/ 9 | beets/ 10 | *.egg-info/ 11 | .vscode/ 12 | .DS_Store 13 | *.pyc 14 | *.swp 15 | .act-secrets 16 | .act-variables 17 | .actrc 18 | .mypy_cache 19 | .ruff_cache 20 | .venv 21 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org/ 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | end_of_line = lf 11 | charset = utf-8 12 | 13 | [*.md, *.py, *.pyi] 14 | max_line_length = 88 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F41B Bug report" 3 | about: Report a problem with Filetote 4 | 5 | --- 6 | 7 | 8 | 9 | 15 | 16 | ## Problem 17 | 18 | Running this command in verbose (`-vv`) mode: 19 | 20 | ```console 21 | beet -vv (... paste here ...) 22 | ``` 23 | 24 | Led to this problem / output: 25 | 26 | ```console 27 | (paste here) 28 | ``` 29 | 30 | My expectation was: 31 | ___ 32 | 33 | ## Setup 34 | 35 | 40 | 41 | * OS: ___ 42 | * Python version: ___ 43 | * beets version: ___ 44 | * Filetote version: ___ 45 | * Turning off other plugins made problem go away (yes/no): ___ 46 | 47 | 52 | 53 | My configuration (output of `beet config`) is: 54 | 55 | ```yaml 56 | (paste here) 57 | ``` 58 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-issue-config.json 2 | 3 | blank_issues_enabled: false 4 | contact_links: 5 | - name: 💡 Have an idea for a new feature? 6 | url: https://github.com/gtronset/beets-filetote/discussions/categories/ideas 7 | about: Create a new idea discussion! 8 | - name: 🙇 Need help with Filetote? 9 | url: https://github.com/gtronset/beets-filetote/discussions/categories/general 10 | about: Create a new help discussion if it hasn't been asked before! 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F680 Feature request" 3 | about: "Formalize a feature request from GitHub Discussions" 4 | 5 | --- 6 | 7 | 8 | 12 | 13 | ## Proposed solution 14 | 15 | 16 | 17 | ## Objective 18 | 19 | 20 | 21 | ## Goals 22 | 23 | 24 | 25 | ### Non-goals 26 | 27 | 31 | 32 | ### Anti-goals 33 | 34 | 37 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/dependabot-2.0.json 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "docker" 6 | directory: "/" 7 | schedule: 8 | interval: "daily" 9 | - package-ecosystem: github-actions 10 | open-pull-requests-limit: 10 11 | directory: "/" 12 | schedule: 13 | interval: weekly 14 | time: "04:00" 15 | - package-ecosystem: pip 16 | open-pull-requests-limit: 10 17 | directory: "/" 18 | schedule: 19 | interval: weekly 20 | time: "04:00" 21 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | ## Description 3 | 4 | Fixes #X. 5 | 6 | (...) 7 | 8 | ## To Do 9 | 10 | 20 | 21 | - [ ] Documentation (update `README.md`) 22 | - [ ] Changelog (add an entry to `CHANGELOG.md`) 23 | - [ ] Tests 24 | -------------------------------------------------------------------------------- /.github/workflows/ci-test-lint.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 2 | 3 | name: CI - Test & Lint 4 | 5 | permissions: 6 | contents: read 7 | 8 | on: 9 | push: 10 | pull_request: 11 | types: [opened, reopened] 12 | 13 | env: 14 | FFMPEG_VERSION: 7.0.2 15 | POETRY_VERSION: 1.8.5 16 | PYTHON_VERSION: 3.13 17 | 18 | defaults: 19 | run: 20 | shell: bash 21 | 22 | jobs: 23 | test: 24 | strategy: 25 | fail-fast: false 26 | matrix: 27 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 28 | os: [ubuntu-latest, windows-latest] 29 | 30 | env: 31 | TOXENV: ${{ matrix.python-version }} 32 | 33 | runs-on: ${{ matrix.os }} 34 | 35 | steps: 36 | - uses: actions/checkout@v4 37 | 38 | - name: Setup FFmpeg Environment Variables 39 | id: ffmpeg-env-setup 40 | run: | 41 | PATH_SEP="${{ !startsWith(runner.os, 'windows') && '/' || '\\' }}" 42 | echo "PATH_SEP=${PATH_SEP}" >> $GITHUB_ENV 43 | 44 | ffmpeg_dir="${{ github.workspace }}${PATH_SEP}ffmpeg_cache" 45 | 46 | echo "ffmpeg-cache-path=${ffmpeg_dir}" >> $GITHUB_OUTPUT 47 | echo "$ffmpeg_dir" >> $GITHUB_PATH 48 | 49 | - name: Cache FFmpeg 50 | uses: actions/cache@v4 51 | with: 52 | path: ${{ steps.ffmpeg-env-setup.outputs.ffmpeg-cache-path }} 53 | key: ${{ runner.os }}-ffmpeg-${{ env.FFMPEG_VERSION }} 54 | 55 | - name: Install FFmpeg (Ubuntu) 56 | if: matrix.os == 'ubuntu-latest' 57 | run: | 58 | # ffmpeg_linux_version=$(curl -L https://johnvansickle.com/ffmpeg/release-readme.txt 2> /dev/null | grep "version: " | cut -c 24-) 59 | ffmpeg_dir="${{ steps.ffmpeg-env-setup.outputs.ffmpeg-cache-path }}" 60 | 61 | echo "$ffmpeg_dir" >> "$GITHUB_PATH" 62 | 63 | # Only install if FFmpeg is not available 64 | if ! $(ffmpeg -version >/dev/null 2>&1) ; then 65 | linux_url='https://johnvansickle.com/ffmpeg/releases/ffmpeg-${{ env.FFMPEG_VERSION }}-amd64-static.tar.xz' 66 | 67 | temp_ffmpeg_archive="${{ github.workspace }}\temp_ffmpeg_archive.tar.xz" 68 | 69 | curl -L "$linux_url" -o "$temp_ffmpeg_archive" 70 | mkdir "$ffmpeg_dir" || true 71 | 72 | tar -xf "$temp_ffmpeg_archive" --wildcards -O "**/ffmpeg" > "$ffmpeg_dir/ffmpeg" 73 | tar -xf "$temp_ffmpeg_archive" --wildcards -O "**/ffprobe" > "$ffmpeg_dir/ffprobe" 74 | tar -xf "$temp_ffmpeg_archive" --wildcards -O "**/GPLv3.txt" > "$ffmpeg_dir/LICENSE" 75 | tar -xf "$temp_ffmpeg_archive" --wildcards -O "**/readme.txt" > "$ffmpeg_dir/README.txt" 76 | 77 | # Ensure these can be executed 78 | chmod +x "$ffmpeg_dir/ffmpeg" "$ffmpeg_dir/ffprobe" 79 | 80 | rm -rf "$temp_ffmpeg_archive" 81 | fi 82 | 83 | - name: Install FFmpeg (Windows) 84 | if: matrix.os == 'windows-latest' 85 | run: | 86 | # ffmpeg_win_version=$(curl -L https://www.gyan.dev/ffmpeg/builds/release-version 2> /dev/null | cut -d "-" -f 1) 87 | ffmpeg_dir="${{ steps.ffmpeg-env-setup.outputs.ffmpeg-cache-path }}" 88 | 89 | echo "$ffmpeg_dir" >> "$GITHUB_PATH" 90 | 91 | # Only install if FFmpeg is not available 92 | if ! $(ffmpeg -version >/dev/null 2>&1) ; then 93 | win32_url="https://www.gyan.dev/ffmpeg/builds/packages/ffmpeg-${{ env.FFMPEG_VERSION }}-full_build.7z" 94 | temp_ffmpeg_archive="${{ github.workspace }}\temp_ffmpeg_archive.7z" 95 | 96 | curl -L "$win32_url" -o "$temp_ffmpeg_archive" 97 | mkdir "$ffmpeg_dir" || true 98 | 99 | 7z e "$temp_ffmpeg_archive" "-o$ffmpeg_dir" "**\bin\ffmpeg.exe" \ 100 | "**\bin\ffprobe.exe" "**\LICENSE" "**\README.txt" 101 | 102 | rm -rf "$temp_ffmpeg_archive" 103 | fi 104 | 105 | - name: Setup Pipx Environment Variables 106 | id: pipx-env-setup 107 | run: | 108 | PATH_SEP="${{ !startsWith(runner.os, 'windows') && '/' || '\\' }}" 109 | echo "PATH_SEP=${PATH_SEP}" >> $GITHUB_ENV 110 | 111 | PIPX_CACHE="${{ github.workspace }}${PATH_SEP}pipx_cache" 112 | echo "pipx-cache-path=${PIPX_CACHE}" >> $GITHUB_OUTPUT 113 | echo "pipx-version=$(pipx --version)" >> $GITHUB_OUTPUT 114 | echo "PIPX_HOME=${PIPX_CACHE}${PATH_SEP}home" >> $GITHUB_ENV 115 | echo "PIPX_BIN_DIR=${PIPX_CACHE}${PATH_SEP}bin" >> $GITHUB_ENV 116 | echo "PIPX_MAN_DIR=${PIPX_CACHE}${PATH_SEP}man" >> $GITHUB_ENV 117 | echo "${PIPX_CACHE}${PATH_SEP}bin" >> $GITHUB_PATH 118 | 119 | - name: Cache Pipx 120 | id: cache-pipx 121 | uses: actions/cache@v4 122 | with: 123 | path: ${{ steps.pipx-env-setup.outputs.pipx-cache-path }} 124 | key: ${{ runner.os }}-python_${{ env.PYTHON_VERSION }}-pipx_${{ steps.pipx-env-setup.outputs.pipx-version }}-poetry_${{ env.POETRY_VERSION }} 125 | 126 | - name: Install Poetry & Tox 127 | if: steps.cache-pipx.outputs.cache-hit != 'true' 128 | run: | 129 | pipx install poetry==${{ env.POETRY_VERSION }} 130 | 131 | - name: Setup Python ${{ matrix.python-version }} 132 | uses: actions/setup-python@v5 133 | with: 134 | python-version: ${{ matrix.python-version }} 135 | cache: poetry 136 | 137 | - name: Install poetry Dependencies 138 | run: poetry install 139 | 140 | - name: Run Tests 141 | run: poetry run pytest 142 | 143 | lint: 144 | runs-on: ubuntu-latest 145 | steps: 146 | - uses: actions/checkout@v4 147 | 148 | - name: Setup Pipx Environment Variables 149 | id: pipx-env-setup 150 | run: | 151 | PATH_SEP="${{ !startsWith(runner.os, 'windows') && '/' || '\\' }}" 152 | 153 | PIPX_CACHE="${{ github.workspace }}${PATH_SEP}pipx_cache" 154 | echo "pipx-cache-path=${PIPX_CACHE}" >> $GITHUB_OUTPUT 155 | echo "pipx-version=$(pipx --version)" >> $GITHUB_OUTPUT 156 | echo "PIPX_HOME=${PIPX_CACHE}${PATH_SEP}home" >> $GITHUB_ENV 157 | echo "PIPX_BIN_DIR=${PIPX_CACHE}${PATH_SEP}bin" >> $GITHUB_ENV 158 | echo "PIPX_MAN_DIR=${PIPX_CACHE}${PATH_SEP}man" >> $GITHUB_ENV 159 | echo "${PIPX_CACHE}${PATH_SEP}bin" >> $GITHUB_PATH 160 | 161 | - name: Cache Pipx 162 | id: cache-pipx 163 | uses: actions/cache@v4 164 | with: 165 | path: ${{ steps.pipx-env-setup.outputs.pipx-cache-path }} 166 | key: ${{ runner.os }}-python_${{ env.PYTHON_VERSION }}-pipx_${{ steps.pipx-env-setup.outputs.pipx-version }}-poetry_${{ env.POETRY_VERSION }} 167 | 168 | - name: Install Poetry & Tox 169 | if: steps.cache-pipx.outputs.cache-hit != 'true' 170 | run: | 171 | pipx install poetry==${{ env.POETRY_VERSION }} 172 | 173 | - name: Setup Python ${{ env.PYTHON_VERSION }} 174 | uses: actions/setup-python@v5 175 | with: 176 | python-version: ${{ env.PYTHON_VERSION }} 177 | cache: poetry 178 | 179 | - name: Install poetry Dependencies 180 | run: poetry install 181 | 182 | - name: Check mypy 183 | if: always() 184 | run: | 185 | poetry run mypy 186 | 187 | - name: Check Ruff (Lint) 188 | if: always() 189 | run: | 190 | poetry run ruff check 191 | 192 | - name: Check Ruff (Format) 193 | if: always() 194 | run: | 195 | poetry run ruff format 196 | -------------------------------------------------------------------------------- /.github/workflows/markdown-lint.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 2 | 3 | name: Markdown Lint 4 | 5 | permissions: 6 | contents: read 7 | 8 | on: 9 | push: 10 | paths: ["**.md", "**.markdown"] 11 | pull_request: 12 | paths: ["**.md", "**.markdown"] 13 | types: [opened, reopened] 14 | 15 | jobs: 16 | markdownlint: 17 | name: Markdown Lint 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout Code 21 | uses: actions/checkout@v4 22 | 23 | - name: Lint Markdown Files 24 | uses: DavidAnson/markdownlint-cli2-action@v20 25 | with: 26 | config: .markdownlint.yaml 27 | globs: | 28 | **.md 29 | **.markdown 30 | !src/** 31 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit-updater.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 2 | 3 | name: Pre-commit Updater 4 | 5 | permissions: 6 | contents: write 7 | pull-requests: write 8 | 9 | on: 10 | schedule: 11 | - cron: "0 4 * * 0" 12 | workflow_dispatch: 13 | 14 | env: 15 | PYTHON_VERSION: 3.13 16 | 17 | jobs: 18 | autoupdate-precommit: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | 23 | - name: Set up Python 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: ${{ env.PYTHON_VERSION }} 27 | cache: pip 28 | 29 | - name: Install pre-commit 30 | run: pip install pre-commit-update 31 | 32 | - name: Run pre-commit autoupdate 33 | run: pre-commit-update 34 | 35 | - name: Create Pull Request 36 | uses: peter-evans/create-pull-request@v7 37 | with: 38 | token: ${{ secrets.GITHUB_TOKEN }} 39 | branch: update/pre-commit-autoupdate 40 | title: Auto-update pre-commit hooks 41 | commit-message: Auto-update pre-commit hooks 42 | body: | 43 | Update versions of tools in pre-commit 44 | configs to latest version 45 | labels: dependencies 46 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 2 | 3 | name: Release 4 | 5 | permissions: 6 | contents: write 7 | 8 | on: 9 | workflow_dispatch: 10 | 11 | env: 12 | POETRY_VERSION: 1.8.5 13 | PYTHON_VERSION: 3.13 14 | 15 | jobs: 16 | release: 17 | name: Release 18 | 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v4 24 | 25 | - name: Setup pipx environment Variables 26 | id: pipx-env-setup 27 | run: | 28 | PATH_SEP="${{ !startsWith(runner.os, 'windows') && '/' || '\\' }}" 29 | echo "PATH_SEP=${PATH_SEP}" >> $GITHUB_ENV 30 | 31 | PIPX_CACHE="${{ github.workspace }}${PATH_SEP}pipx_cache" 32 | echo "pipx-cache-path=${PIPX_CACHE}" >> $GITHUB_OUTPUT 33 | echo "pipx-version=$(pipx --version)" >> $GITHUB_OUTPUT 34 | echo "PIPX_HOME=${PIPX_CACHE}${PATH_SEP}home" >> $GITHUB_ENV 35 | echo "PIPX_BIN_DIR=${PIPX_CACHE}${PATH_SEP}bin" >> $GITHUB_ENV 36 | echo "PIPX_MAN_DIR=${PIPX_CACHE}${PATH_SEP}man" >> $GITHUB_ENV 37 | echo "${PIPX_CACHE}${PATH_SEP}bin" >> $GITHUB_PATH 38 | 39 | - name: Cache Pipx 40 | id: cache-pipx 41 | uses: actions/cache@v4 42 | with: 43 | path: ${{ steps.pipx-env-setup.outputs.pipx-cache-path }} 44 | key: ${{ runner.os }}-python_${{ env.PYTHON_VERSION }}-pipx_${{ steps.pipx-env-setup.outputs.pipx-version }}-poetry_${{ env.POETRY_VERSION }} 45 | 46 | - name: Install Poetry & Tox 47 | if: steps.cache-pipx.outputs.cache-hit != 'true' 48 | run: | 49 | pipx install poetry==${{ env.POETRY_VERSION }} 50 | 51 | - name: Set up Python 52 | uses: actions/setup-python@v5 53 | with: 54 | python-version: ${{ env.PYTHON_VERSION }} 55 | cache: poetry 56 | 57 | - name: Set Poetry to use TestPyPi to allow building against the test env 58 | if: ${{ github.event.act }} 59 | run: | 60 | poetry config repositories.test-pypi https://test.pypi.org/legacy/ 61 | poetry config pypi-token.test-pypi ${{ secrets.PYPI_TOKEN }} 62 | echo "POETRY_REPO=-r test-pypi" >> $GITHUB_ENV 63 | 64 | - name: Build project for distribution 65 | run: poetry build 66 | 67 | - name: Tag Name 68 | id: tag-name 69 | uses: SebRollen/toml-action@v1.2.0 70 | with: 71 | file: "pyproject.toml" 72 | field: "tool.poetry.version" 73 | 74 | - name: Check Version 75 | id: check-version 76 | run: | 77 | [[ "$(poetry version --short)" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || echo prerelease=true >> $GITHUB_OUTPUT 78 | 79 | - name: Create Release 80 | if: ${{ !github.event.act }} 81 | uses: ncipollo/release-action@v1 82 | with: 83 | artifacts: "dist/*" 84 | token: ${{ secrets.GITHUB_TOKEN }} 85 | tag: "v${{ steps.tag-name.outputs.value }}" 86 | draft: false 87 | generateReleaseNotes: true 88 | prerelease: steps.check-version.outputs.prerelease == 'true' 89 | 90 | - name: Publish to PyPI 91 | env: 92 | POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_TOKEN }} 93 | run: poetry publish ${{ env.POETRY_REPO }} 94 | -------------------------------------------------------------------------------- /.github/workflows/typos.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 2 | 3 | name: Typos Check 4 | 5 | permissions: 6 | contents: read 7 | 8 | on: 9 | push: 10 | pull_request: 11 | types: [opened, reopened] 12 | 13 | jobs: 14 | run: 15 | name: Spell Check with Typos 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout Actions Repository 19 | uses: actions/checkout@v4 20 | 21 | - name: Run 22 | uses: crate-ci/typos@v1.32.0 23 | with: 24 | config: ./_typos.toml 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.swp 3 | .tox/ 4 | .pytest_cache/ 5 | .pypy_cache/ 6 | __pycache__/ 7 | dist/ 8 | build/ 9 | env/ 10 | ./beets/ 11 | *.egg-info/ 12 | .vscode/ 13 | .DS_Store 14 | .act-secrets 15 | .act-variables 16 | .mypy_cache 17 | .ruff_cache 18 | .venv/ 19 | -------------------------------------------------------------------------------- /.markdownlint.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://raw.githubusercontent.com/DavidAnson/markdownlint/main/schema/markdownlint-config-schema.json 2 | 3 | # MD007/ul-indent - Unordered list indentation 4 | MD007: 5 | # Spaces for indent 6 | indent: 4 7 | # MD010/no-hard-tabs - Hard tabs 8 | MD010: 9 | # Include code blocks 10 | code_blocks: true 11 | # Number of spaces for each hard tab 12 | spaces_per_tab: 4 13 | # MD013/line-length - Line length 14 | MD013: 15 | code_block_line_length: 88 # Override default of 80 characters 16 | heading_line_length: 88 # Override default of 80 characters 17 | line_length: 88 # Override default of 80 characters 18 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/pre-commit-config.json 2 | 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v5.0.0 6 | hooks: 7 | - id: check-yaml 8 | - id: check-toml 9 | - id: fix-encoding-pragma 10 | args: [--remove] 11 | - id: end-of-file-fixer 12 | - id: trailing-whitespace 13 | args: [--markdown-linebreak-ext=md] 14 | - id: mixed-line-ending 15 | args: ["--fix=lf"] 16 | - id: no-commit-to-branch 17 | - id: check-added-large-files 18 | args: ["--maxkb=500"] 19 | 20 | - repo: https://github.com/astral-sh/ruff-pre-commit 21 | rev: v0.11.10 22 | hooks: 23 | - id: ruff 24 | name: lint 25 | alias: lint 26 | - id: ruff 27 | name: lint-fix 28 | alias: lint-fix 29 | args: [--fix] 30 | stages: [manual] 31 | - id: ruff-format 32 | name: format 33 | alias: format 34 | 35 | - repo: https://github.com/igorshubovych/markdownlint-cli 36 | rev: v0.45.0 37 | hooks: 38 | - id: markdownlint 39 | - id: markdownlint 40 | name: markdownlint-fix 41 | alias: markdownlint-fix 42 | args: ["--fix"] 43 | stages: [manual] 44 | 45 | - repo: https://github.com/pre-commit/pre-commit 46 | rev: v4.2.0 47 | hooks: 48 | - id: validate_manifest 49 | 50 | - repo: https://github.com/pre-commit/mirrors-mypy 51 | rev: v1.15.0 52 | hooks: 53 | - id: mypy 54 | additional_dependencies: [pytest] 55 | 56 | - repo: https://gitlab.com/vojko.pribudic.foss/pre-commit-update 57 | rev: v0.7.0 58 | hooks: 59 | - id: pre-commit-update 60 | stages: [manual] 61 | 62 | - repo: https://github.com/python-poetry/poetry 63 | rev: 1.8.5 64 | hooks: 65 | - id: poetry-check 66 | stages: [push] 67 | - id: poetry-lock 68 | stages: [push] 69 | - id: poetry-export 70 | name: poetry-export 71 | stages: [manual] 72 | files: (pyproject\.toml|poetry\.lock) 73 | args: 74 | - "--without-hashes" 75 | - "-f" 76 | - "requirements.txt" 77 | - "-o" 78 | - "requirements.txt" 79 | - id: poetry-export 80 | name: poetry-export-dev 81 | alias: poetry-export-dev 82 | stages: [manual] 83 | files: (pyproject\.toml|poetry\.lock) 84 | args: 85 | - "--with" 86 | - "dev,lint,test" 87 | - "--without-hashes" 88 | - "-f" 89 | - "requirements.txt" 90 | - "-o" 91 | - "dev-requirements.txt" 92 | 93 | - repo: https://github.com/crate-ci/typos 94 | # https://github.com/crate-ci/typos/issues/390 95 | rev: v1.32.0 96 | hooks: 97 | - id: typos 98 | exclude: "(_typos.toml|.gitignore)" 99 | 100 | - repo: https://github.com/abravalheri/validate-pyproject 101 | rev: v0.24.1 102 | hooks: 103 | - id: validate-pyproject 104 | additional_dependencies: 105 | - "validate-pyproject[all]" 106 | - "validate-pyproject-schema-store" 107 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | python 3.13.3 3.12.10 3.11.12 3.10.17 3.9.22 3.8.20 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Changelog 4 | 5 | All notable changes to this project will be documented in this file. 6 | 7 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 8 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 9 | 10 | ## [Unreleased] 11 | 12 | ## Fixed 13 | 14 | - Fix Readme CI URLs for new Workflow name 15 | 16 | ## [1.0.3] - 2025-05-19 17 | 18 | ### Changed 19 | 20 | - Explicitly set `permissions` on all Workflows 21 | - Add better Workflow caching & other CI performance tweaks 22 | - Switch to using `pre-commit-update` instead of `pre-commit autoupdate` 23 | - Use Poetry to manage Tox dependencies 24 | 25 | ### Fixed 26 | 27 | - Handle `convert` temp file locations overriding `Item.path` 28 | - Fix CHANGELOG (duplicate heading) 29 | 30 | ## [1.0.2] - 2025-05-14 31 | 32 | ### Changed 33 | 34 | - Improve the `exclude` setting to allow filenames, extensions, and patterns 35 | - Update `ruff` to `0.11.9` 36 | - Provide `filetote:default` to configure custom default renaming for otherwise 37 | unspecified artifacts 38 | - Import `annotations` and improve typing for py38+ 39 | 40 | ### Fixed 41 | 42 | - Bugfix: allow list of strings for deprecated `exclude` config 43 | - Update Pre-commit Updated permissions to allow write 44 | - Add tests to ensure priority and order for `paths` queries 45 | 46 | ## [1.0.1] - 2025-05-08 47 | 48 | ### Changed 49 | 50 | - Update Readme to fix link 51 | - Migrate to Ruff for Code Formatting and Linting 52 | - Move tox config to pyproject.toml & add CONTRIBUTING 53 | - Allow contents write permission in release workflow 54 | 55 | ### Fixed 56 | 57 | - Bugfix: Apply `replace` settings when renaming artifacts 58 | 59 | ## [1.0.0] - 2025-05-06 60 | 61 | ### Changed 62 | 63 | - Update Black version to fix vulnerability 64 | - Update Filetote to support Beets >=2.0.0 (and various cleanups) 65 | 66 | ## [0.4.9] - 2024-04-20 67 | 68 | ### Changed 69 | 70 | - Update beets version & other dependency requirements 71 | 72 | ## [0.4.8] - 2024-01-03 73 | 74 | ### Added 75 | 76 | - Add Security policy 77 | - Allow for retaining path hierarchy 78 | 79 | ### Changed 80 | 81 | - Test and ensure subdirectory with $albumpath 82 | - Remove need for typing_extensions in plugin 83 | 84 | ## [0.4.7] - 2023-12-17 85 | 86 | ### Added 87 | 88 | - Add tests to ensure inline plugin works 89 | - Bugfix for Update CLI command 90 | 91 | ### Changed 92 | 93 | - Improve README, Dev setup, & update mediafile for newer Py versions 94 | - Various dependency updates 95 | 96 | ## [0.4.6] - 2023-12-03 97 | 98 | ### Fixed 99 | 100 | - Fix reimport via query (IndexError bugfix) 101 | - Refactor and Fix Pruning 102 | 103 | ## [0.4.5] - 2023-12-01 104 | 105 | ### Added 106 | 107 | - Enable Filetote on "move" command 108 | 109 | ## [0.4.4] - 2023-11-23 110 | 111 | ### Fixed 112 | 113 | - Reevaluate & fix types (now with stubs!) 114 | 115 | ## [0.4.3] - 2023-10-05 116 | 117 | ### Fixed 118 | 119 | - Fix "cannot find the file specified" bug 120 | 121 | ### Changed 122 | 123 | - Add support for Python 3.12 (py312) 124 | - Various dependency updates 125 | 126 | ## [0.4.2] - 2023-05-19 127 | 128 | ### Changed 129 | 130 | - Loosen Python version in Dockerfile 131 | - Use the formatted version of Item fields 132 | 133 | ## [0.4.1] - 2023-05-15 134 | 135 | ### Changed 136 | 137 | - Refactor fields to allow Beets Item values 138 | 139 | ## [0.4.0] - 2023-05-14 140 | 141 | ### Added 142 | 143 | - Add tox command for doing black changes 144 | - Add mypy 145 | - Allow paired files to be by ext 146 | - Add flake8-bugbear & fix errors 147 | - Add pattern match and alternative path format config section 148 | 149 | ### Changed 150 | 151 | - Misc. Refactors to Filetote 152 | - Test Suite Refactoring 153 | - Add additional, stricter mypy settings 154 | - Add testing for nested directories / multi-disc imports 155 | - Various dependency updates 156 | 157 | ## [0.3.3] - 2023-01-08 158 | 159 | ### Fixed 160 | 161 | - Bugfix of mediafile types - Exclude m4a, m4b, etc. 162 | 163 | ### Changed 164 | 165 | - Add Release Action to CI in 166 | - Only test py11 on Ubuntu in 167 | 168 | ## [0.3.2] - 2022-12-30 169 | 170 | ### Fixed 171 | 172 | - MoveOperation bugfix for CLI overrides 173 | 174 | ## [0.3.1] - 2022-12-26 175 | 176 | ### Added 177 | 178 | - Add Flake8 179 | - Add Pylint 180 | - Auto-update pre-commit hooks 181 | - Add Poetry 182 | 183 | ### Changed 184 | 185 | - Rename `master` branch to `main` 186 | 187 | ## [0.3.0] - 2022-12-21 188 | 189 | ### Added 190 | 191 | - CHANGELOG 192 | 193 | ### Changed 194 | 195 | - Fix py3.6 CI issue & ignore py3.11 Win fails by @gtronset in 196 | - Bump DavidAnson/markdownlint-cli2-action from 7 to 8 by @dependabot in 197 | - Bump python from 3.11.0-alpine to 3.11.1-alpine by @dependabot in 198 | - Rename plugin to Filetote by @gtronset in 199 | 200 | ## [0.2.2] - 2022-11-10 201 | 202 | ### Added 203 | 204 | - Introduce "paired" file copies by @gtronset in 205 | - Introduce reflinks, hardlinks, and symlinks by @gtronset in 206 | 207 | ### Changed 208 | 209 | - Small update to specifying namespace by @gtronset in 210 | - Add tests for renaming illegal chars by @gtronset in 211 | 212 | ## [0.2.1] - 2022-11-01 213 | 214 | ### Added 215 | 216 | - Add "filenames" and "exclude" options by @gtronset in 217 | - Improve path/renaming options by @gtronset in 218 | 219 | ### Changed 220 | 221 | - Smarter file renaming with `filename:` path query prioritized by @gtronset in 222 | - Pruning and test improvements by @gtronset in 223 | - Lock mediafile requirement to ==0.10.0 by @dependabot in 224 | - Bump python from 3.10-alpine to 3.11.0-alpine by @dependabot in 225 | 226 | ## [0.2.0] - 2022-10-21 227 | 228 | ### Added 229 | 230 | - Rename and modernize multiple files by @gtronset in 231 | - Bump requests from 2.22.0 to 2.28.1 by @dependabot in 232 | - Bump mock from 2.0.0 to 4.0.3 by @dependabot in 233 | - Fix LICENSE and cleanup README by @gtronset in 234 | - Add pre-commit, black, isort, updates to setup.cfg by @gtronset in 235 | - Add typo and MD Checks by @gtronset in 236 | - Update compose to stay-alive by @gtronset in 237 | 238 | ### Updated 239 | 240 | - Hard Fork from 241 | 242 | 243 | 244 | [unreleased]: https://github.com/gtronset/beets-filetote/compare/v1.0.3...HEAD 245 | [1.0.3]: https://github.com/gtronset/beets-filetote/releases/tag/v1.0.3 246 | [1.0.2]: https://github.com/gtronset/beets-filetote/releases/tag/v1.0.2 247 | [1.0.1]: https://github.com/gtronset/beets-filetote/releases/tag/v1.0.1 248 | [1.0.0]: https://github.com/gtronset/beets-filetote/releases/tag/v1.0.0 249 | [0.4.9]: https://github.com/gtronset/beets-filetote/releases/tag/v0.4.9 250 | [0.4.8]: https://github.com/gtronset/beets-filetote/releases/tag/v0.4.8 251 | [0.4.7]: https://github.com/gtronset/beets-filetote/releases/tag/v0.4.7 252 | [0.4.6]: https://github.com/gtronset/beets-filetote/releases/tag/v0.4.6 253 | [0.4.5]: https://github.com/gtronset/beets-filetote/releases/tag/v0.4.5 254 | [0.4.4]: https://github.com/gtronset/beets-filetote/releases/tag/v0.4.4 255 | [0.4.3]: https://github.com/gtronset/beets-filetote/releases/tag/v0.4.3 256 | [0.4.2]: https://github.com/gtronset/beets-filetote/releases/tag/v0.4.2 257 | [0.4.1]: https://github.com/gtronset/beets-filetote/releases/tag/v0.4.1 258 | [0.4.0]: https://github.com/gtronset/beets-filetote/releases/tag/v0.4.0 259 | [0.3.3]: https://github.com/gtronset/beets-filetote/releases/tag/v0.3.3 260 | [0.3.2]: https://github.com/gtronset/beets-filetote/releases/tag/v0.3.2 261 | [0.3.1]: https://github.com/gtronset/beets-filetote/releases/tag/v0.3.1 262 | [0.3.0]: https://github.com/gtronset/beets-filetote/releases/tag/v0.3.0 263 | [0.2.2]: https://github.com/gtronset/beets-filetote/releases/tag/v0.2.2 264 | [0.2.1]: https://github.com/gtronset/beets-filetote/releases/tag/v0.2.1 265 | [0.2.0]: https://github.com/gtronset/beets-filetote/releases/tag/v0.2.0 266 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community 6 | a harassment-free experience for everyone, regardless of age, body size, visible or 7 | invisible disability, ethnicity, sex characteristics, gender identity and expression, 8 | level of experience, education, socio-economic status, nationality, personal appearance, 9 | race, religion, or sexual identity and orientation. 10 | 11 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, 12 | inclusive, and healthy community. 13 | 14 | ## Our Standards 15 | 16 | Examples of behavior that contributes to a positive environment for our community 17 | include: 18 | 19 | * Demonstrating empathy and kindness toward other people 20 | * Being respectful of differing opinions, viewpoints, and experiences 21 | * Giving and gracefully accepting constructive feedback 22 | * Accepting responsibility and apologizing to those affected by our mistakes, and 23 | learning from the experience 24 | * Focusing on what is best not just for us as individuals, but for the overall 25 | community 26 | 27 | Examples of unacceptable behavior include: 28 | 29 | * The use of sexualized language or imagery, and sexual attention or 30 | advances of any kind 31 | * Trolling, insulting or derogatory comments, and personal or political attacks 32 | * Public or private harassment 33 | * Publishing others' private information, such as a physical or email 34 | address, without their explicit permission 35 | * Other conduct which could reasonably be considered inappropriate in a 36 | professional setting 37 | 38 | ## Enforcement Responsibilities 39 | 40 | Community leaders are responsible for clarifying and enforcing our standards of 41 | acceptable behavior and will take appropriate and fair corrective action in response to 42 | any behavior that they deem inappropriate, threatening, offensive, or harmful. 43 | 44 | Community leaders have the right and responsibility to remove, edit, or reject 45 | comments, commits, code, wiki edits, issues, and other contributions that are not 46 | aligned to this Code of Conduct, and will communicate reasons for moderation decisions 47 | when appropriate. 48 | 49 | ## Scope 50 | 51 | This Code of Conduct applies within all community spaces, and also applies when an 52 | individual is officially representing the community in public spaces. Examples of 53 | representing our community include using an official e-mail address, posting via an 54 | official social media account, or acting as an appointed representative at an online or 55 | offline event. 56 | 57 | ## Enforcement 58 | 59 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to 60 | the community leaders responsible for enforcement here on Github. All complaints will 61 | be reviewed and investigated promptly and fairly. 62 | 63 | All community leaders are obligated to respect the privacy and security of the reporter 64 | of any incident. 65 | 66 | ## Enforcement Guidelines 67 | 68 | Community leaders will follow these Community Impact Guidelines in determining the 69 | consequences for any action they deem in violation of this Code of Conduct: 70 | 71 | ### 1. Correction 72 | 73 | **Community Impact**: Use of inappropriate language or other behavior deemed 74 | unprofessional or unwelcome in the community. 75 | 76 | **Consequence**: A private, written warning from community leaders, providing clarity 77 | around the nature of the violation and an explanation of why the behavior was 78 | inappropriate. A public apology may be requested. 79 | 80 | ### 2. Warning 81 | 82 | **Community Impact**: A violation through a single incident or series of actions. 83 | 84 | **Consequence**: A warning with consequences for continued behavior. No interaction 85 | with the people involved, including unsolicited interaction with those enforcing the 86 | Code of Conduct, for a specified period of time. This includes avoiding interactions in 87 | community spaces as well as external channels like social media. Violating these terms 88 | may lead to a temporary or permanent ban. 89 | 90 | ### 3. Temporary Ban 91 | 92 | **Community Impact**: A serious violation of community standards, including sustained 93 | inappropriate behavior. 94 | 95 | **Consequence**: A temporary ban from any sort of interaction or public communication 96 | with the community for a specified period of time. No public or private interaction 97 | with the people involved, including unsolicited interaction with those enforcing the 98 | Code of Conduct, is allowed during this period. Violating these terms may lead to a 99 | permanent ban. 100 | 101 | ### 4. Permanent Ban 102 | 103 | **Community Impact**: Demonstrating a pattern of violation of community standards, 104 | including sustained inappropriate behavior, harassment of an individual, or aggression 105 | toward or disparagement of classes of individuals. 106 | 107 | **Consequence**: A permanent ban from any sort of public interaction within the 108 | community. 109 | 110 | ## Attribution 111 | 112 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 113 | version 2.0, available at 114 | . 115 | 116 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 117 | enforcement ladder](https://github.com/mozilla/diversity). 118 | 119 | [homepage]: https://www.contributor-covenant.org 120 | 121 | For answers to common questions about this code of conduct, see the FAQ at 122 | . Translations are available at 123 | . 124 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Development & Contributing 2 | 3 | The development version can be installed with [Poetry], a Python dependency manager 4 | that provides dependency isolation, reproducibility, and streamlined packaging to PyPI. 5 | Filetote currently support Poetry `v1.8`. 6 | 7 | Testing and linting is performed with [Tox] (`v4.12`+). 8 | 9 | [Poetry]: https://python-poetry.org/ 10 | [Tox]: https://tox.wiki/ 11 | 12 | It is also highly recommended to [install `pre-commit`], which will help automatically 13 | lint before committing. 14 | 15 | Filetote currently supports Python `3.8`+, which aligns with the most recent version of 16 | beets (`v2.2.0`). 17 | 18 | For general information of working with Beets plugins, see the Beets documumentation 19 | [For Developers] 20 | 21 | [install `pre-commit`]: https://pre-commit.com/#install 22 | [For Developers]: https://beets.readthedocs.io/en/stable/dev/ 23 | 24 | **1. Install Poetry & Tox:** 25 | 26 | ```sh 27 | python3 -m pip install poetry tox 28 | ``` 29 | 30 | **2. Clone the repository and install the plugin:** 31 | 32 | ```sh 33 | git clone https://github.com/gtronset/beets-filetote.git 34 | cd beets-filetote 35 | poetry install 36 | ``` 37 | 38 | **3. Update the config.yaml to utilize the plugin:** 39 | 40 | ```yaml 41 | pluginpath: 42 | - /path/to.../beets-filetote/beetsplug 43 | ``` 44 | 45 | **4. Run or test with Poetry (and Tox):** 46 | 47 | Run beets with the following to locally develop: 48 | 49 | ```sh 50 | poetry run beet 51 | ``` 52 | 53 | Testing can be run with Tox, ex.: 54 | 55 | ```sh 56 | poetry run tox -e 3.13 57 | ``` 58 | 59 | For other linting environments, see `pyproject.toml`. Ex: `lint` (courtesy of `ruff`): 60 | 61 | ```sh 62 | poetry run tox -e lint 63 | ``` 64 | 65 | Ex: `format` (courtesy of `ruff`): 66 | 67 | ```sh 68 | poetry run tox -e format 69 | ``` 70 | 71 | Running `poetry run` before every command can be tedious. Instead, you can activate the 72 | virtual environment in your shell with: 73 | 74 | ```sh 75 | poetry shell 76 | ``` 77 | 78 | Configuration of Tox follows [Poetry's recommended strategy #2], which allows Poetry 79 | to manage dependencies but still allow Tox to manage a distinct environment. Test for 80 | all supported Python versions can be run with the base `tox` command, and can be run in 81 | parallel via `tox -p`, i.e.: 82 | 83 | ```sh 84 | poetry run tox -p 85 | ``` 86 | 87 | [Poetry's recommended strategy #2]: https://python-poetry.org/docs/1.8/faq/#use-case-2 88 | 89 | **Docker:** 90 | 91 | A Docker Compose configuration is available for running the plugin in a controlled 92 | environment. Running the `compose.yaml` file for details. 93 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13-alpine 2 | 3 | # Add dependencies for the reflink python module 4 | RUN apk update && apk add python3-dev \ 5 | cargo \ 6 | ffmpeg \ 7 | gcc \ 8 | gdal \ 9 | libc-dev \ 10 | libffi-dev \ 11 | && rm -rf /var/cache/apk/* 12 | 13 | WORKDIR /src 14 | 15 | RUN mkdir -p /beets/library && mkdir -p /beets/inbox 16 | 17 | COPY . /src 18 | COPY example.config.yaml /root/.config/beets/config.yaml 19 | 20 | RUN pip install --upgrade pip \ 21 | && pip install beets poetry pre-commit tox \ 22 | && poetry install 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022-present Gavin Tronset 4 | Copyright (c) 2020 Adam Miller 5 | Copyright (c) 2014 Sami Barakat 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | We currently support only the latest release of Filetote. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | To report a security vulnerability, please send an email to [our team] 10 | 11 | [our team]:mailto:security@tritones.io 12 | -------------------------------------------------------------------------------- /_typos.toml: -------------------------------------------------------------------------------- 1 | [files] 2 | extend-exclude = [ 3 | # Exlude this file, which contains examples of typos 4 | "_typos.toml", 5 | ".gitignore", 6 | ] 7 | -------------------------------------------------------------------------------- /beetsplug/__init__.py: -------------------------------------------------------------------------------- 1 | """Package for the beets-filetote plugin.""" 2 | 3 | from pkgutil import extend_path 4 | 5 | __path__ = extend_path(__path__, __name__) 6 | 7 | __version__ = "1.0.3" 8 | -------------------------------------------------------------------------------- /beetsplug/filetote_dataclasses.py: -------------------------------------------------------------------------------- 1 | """Dataclasses for Filetote representing Settings/Config-related content along with 2 | data used in processing extra files/artifacts. 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | from dataclasses import asdict, dataclass, field, fields 8 | from sys import version_info 9 | 10 | # Dict and List are needed for py38 11 | # Optional and Union are needed for = (3, 10): 30 | from typing import TypeAlias 31 | else: 32 | from typing_extensions import TypeAlias 33 | 34 | StrSeq: TypeAlias = List[str] 35 | OptionalStrSeq: TypeAlias = Union[Literal[""], StrSeq] 36 | PatternsDict: TypeAlias = Dict[str, List[str]] 37 | PathBytes: TypeAlias = bytes 38 | 39 | DEFAULT_ALL_GLOB: Literal[".*"] = ".*" 40 | DEFAULT_EMPTY: Literal[""] = "" 41 | 42 | 43 | @dataclass 44 | class FiletoteArtifact: 45 | """An individual Filetote Artifact item for processing.""" 46 | 47 | path: PathBytes 48 | paired: bool 49 | 50 | 51 | @dataclass 52 | class FiletoteArtifactCollection: 53 | """An individual Filetote Item collection for processing.""" 54 | 55 | artifacts: list[FiletoteArtifact] 56 | mapping: FiletoteMappingModel 57 | source_path: PathBytes 58 | item_dest: PathBytes 59 | 60 | 61 | @dataclass 62 | class FiletoteSessionData: 63 | """Configuration settings for Filetote Item.""" 64 | 65 | operation: Optional[MoveOperation] = None 66 | _beets_lib: Optional[Library] = None 67 | import_path: Optional[PathBytes] = None 68 | 69 | @property 70 | def beets_lib(self) -> Library: 71 | """Ensures the Beets Library is accessible and present.""" 72 | assert self._beets_lib is not None 73 | return self._beets_lib 74 | 75 | def adjust(self, attr: str, value: Any) -> None: 76 | """Adjust provided attribute of class with provided value.""" 77 | setattr(self, attr, value) 78 | 79 | 80 | @dataclass 81 | class FiletoteExcludeData: 82 | """Configuration settings for Filetote Exclude. Accepts either a sequence/list of 83 | strings (type `list[str]`, for backwards compatibility) or a dict with `filenames`, 84 | `extensions`, and/or `patterns` specified. 85 | 86 | `filenames` is intentionally placed first to ensure backwards compatibility. 87 | """ 88 | 89 | filenames: OptionalStrSeq = DEFAULT_EMPTY 90 | extensions: OptionalStrSeq = DEFAULT_EMPTY 91 | patterns: PatternsDict = field(default_factory=dict) 92 | 93 | def __post_init__(self) -> None: 94 | """Validates types upon initialization.""" 95 | self._validate_types() 96 | 97 | def _validate_types(self) -> None: 98 | """Validate types for Filetote Pairing settings.""" 99 | for field_ in fields(self): 100 | field_value = getattr(self, field_.name) 101 | 102 | if field_.name in { 103 | "filenames", 104 | "extensions", 105 | }: 106 | _validate_types_str_seq( 107 | ["exclude", field_.name], field_value, DEFAULT_EMPTY 108 | ) 109 | 110 | if field_.name == "patterns": 111 | _validate_types_dict( 112 | ["exclude", field_.name], 113 | field_value, 114 | field_type=list, 115 | list_subtype=str, 116 | ) 117 | 118 | 119 | @dataclass 120 | class FiletotePairingData: 121 | """Configuration settings for Filetote Pairing. 122 | 123 | Attributes: 124 | enabled: Whether `pairing` should apply. 125 | pairing_only: Override setting to _only_ target paired files. 126 | extensions: Extensions to target. Defaults to 127 | _all_ extensions (`.*`). 128 | """ 129 | 130 | enabled: bool = False 131 | pairing_only: bool = False 132 | extensions: Union[Literal[".*"], StrSeq] = DEFAULT_ALL_GLOB 133 | 134 | def __post_init__(self) -> None: 135 | """Validates types upon initialization.""" 136 | self._validate_types() 137 | 138 | def _validate_types(self) -> None: 139 | """Validate types for Filetote Pairing settings.""" 140 | for field_ in fields(self): 141 | field_value = getattr(self, field_.name) 142 | field_type = get_type_hints(FiletotePairingData)[field_.name] 143 | 144 | if field_.name in { 145 | "enabled", 146 | "pairing_only", 147 | }: 148 | _validate_types_instance( 149 | ["pairing", field_.name], field_value, field_type 150 | ) 151 | 152 | if field_.name == "extensions": 153 | _validate_types_str_seq( 154 | ["pairing", field_.name], field_value, DEFAULT_ALL_GLOB 155 | ) 156 | 157 | 158 | @dataclass 159 | class FiletoteConfig: 160 | """Configuration settings for Filetote Item. 161 | 162 | Attributes: 163 | session: Beets import session data. Populated once the 164 | `import_begin` is triggered. 165 | extensions: List of extensions of artifacts to target. 166 | filenames: List of filenames of artifacts to target. 167 | patterns: Dictionary of `glob` pattern-matched patterns 168 | of artifacts to target. 169 | exclude: Filenames, extensions, and/or patterns of 170 | artifacts to exclude. 171 | pairing: Settings that control whether to look for pairs 172 | and how to handle them. 173 | paths: Filetote-level configuration of target queries and 174 | paths to define how artifact files should be renamed. 175 | print_ignored: Whether to output lists of ignored artifacts to the 176 | console as imports finish. 177 | """ 178 | 179 | session: FiletoteSessionData = field(default_factory=FiletoteSessionData) 180 | extensions: OptionalStrSeq = DEFAULT_EMPTY 181 | filenames: OptionalStrSeq = DEFAULT_EMPTY 182 | patterns: PatternsDict = field(default_factory=dict) 183 | exclude: FiletoteExcludeData = field(default_factory=FiletoteExcludeData) 184 | pairing: FiletotePairingData = field(default_factory=FiletotePairingData) 185 | paths: Dict[str, str] = field(default_factory=dict) 186 | print_ignored: bool = False 187 | 188 | def __post_init__(self) -> None: 189 | """Validates types upon initialization.""" 190 | self._validate_types() 191 | 192 | def asdict(self) -> dict: # type: ignore[type-arg] 193 | """Returns a `dict` version of the dataclass.""" 194 | return asdict(self) 195 | 196 | def adjust(self, attr: str, value: Any) -> None: 197 | """Adjust provided attribute of class with provided value. For the `pairing` 198 | and `exclude` properties, use the corresponding dataclass and expand the 199 | incoming value to the proper to arguments. 200 | """ 201 | if attr == "exclude": 202 | if isinstance(value, list): 203 | value = FiletoteExcludeData(value) 204 | else: 205 | value = FiletoteExcludeData(**value) 206 | 207 | if attr == "pairing": 208 | value = FiletotePairingData(**value) 209 | 210 | self._validate_types(attr, value) 211 | setattr(self, attr, value) 212 | 213 | def _validate_types( 214 | self, target_field: str | None = None, target_value: Any = None 215 | ) -> None: 216 | """Validate types for Filetote Config settings.""" 217 | for field_ in fields(self): 218 | field_value = target_value or getattr(self, field_.name) 219 | field_type = get_type_hints(FiletoteConfig)[field_.name] 220 | 221 | if target_field and field_.name != target_field: 222 | continue 223 | 224 | if field_.name in { 225 | "exclude", 226 | "session", 227 | "pairing", 228 | }: 229 | _validate_types_instance([field_.name], field_value, field_type) 230 | 231 | if field_.name in {"extensions", "filenames"}: 232 | _validate_types_str_seq([field_.name], field_value, DEFAULT_EMPTY) 233 | 234 | if field_.name == "patterns": 235 | _validate_types_dict( 236 | [field_.name], field_value, field_type=list, list_subtype=str 237 | ) 238 | 239 | if field_.name == "paths": 240 | _validate_types_dict([field_.name], field_value, field_type=str) 241 | 242 | if field_.name == "print_ignored": 243 | _validate_types_instance([field_.name], field_value, field_type) 244 | 245 | 246 | def _validate_types_instance( 247 | field_name: list[str], 248 | field_value: Any, 249 | field_type: Any, 250 | ) -> None: 251 | """A simple `instanceof` comparison.""" 252 | if not isinstance(field_value, field_type): 253 | _raise_type_validation_error( 254 | field_name, 255 | field_type, 256 | field_value, 257 | ) 258 | 259 | 260 | def _validate_types_dict( 261 | field_name: list[str], 262 | field_value: Dict[Any, Any], 263 | field_type: Any, 264 | list_subtype: Any | None = None, 265 | ) -> None: 266 | for key, value in field_value.items(): 267 | if not isinstance(key, str): 268 | _raise_type_validation_error( 269 | field_name, 270 | "string (`str`)", 271 | key, 272 | key_name=key, 273 | ) 274 | 275 | if not isinstance(value, field_type): 276 | _raise_type_validation_error(field_name, "string (`str`)", value, key, True) 277 | 278 | if list_subtype: 279 | for elem in value: 280 | if not isinstance(elem, list_subtype): 281 | _raise_type_validation_error( 282 | field_name, 283 | f"(inner element of the list) {list_subtype}", 284 | elem, 285 | ) 286 | 287 | 288 | def _validate_types_str_seq( 289 | field_name: list[str], 290 | field_value: Any, 291 | optional_default: str, 292 | ) -> None: 293 | if field_value != optional_default: 294 | if not isinstance(field_value, list): 295 | _raise_type_validation_error( 296 | field_name, 297 | f"literal `{optional_default}`, an empty list, or sequence/list of" 298 | " strings (type `list[str]`)", 299 | field_value, 300 | ) 301 | 302 | for elem in field_value: 303 | if not isinstance(elem, str): 304 | _raise_type_validation_error( 305 | field_name, 306 | "sequence/list of strings (type `list[str]`)", 307 | elem, 308 | ) 309 | 310 | 311 | def _raise_type_validation_error( 312 | field_name: list[str], 313 | expected_type: Any, 314 | value: Any = None, 315 | key_name: Any | None = None, 316 | check_keys_value: bool | None = False, 317 | ) -> None: 318 | part_type: str = "Value" 319 | received_type: Any = type(value) 320 | 321 | if key_name: 322 | part_type = f'Key "{key_name}"' 323 | received_type = type(key_name) 324 | 325 | if check_keys_value: 326 | part_type = f"{part_type}'s Value" 327 | received_type = type(value) 328 | 329 | raise TypeError( 330 | f'{part_type} for Filetote config key "{_format_config_hierarchy(field_name)}"' 331 | f" should be of type {expected_type}, got `{received_type}`" 332 | ) 333 | 334 | 335 | def _format_config_hierarchy(parts: list[str]) -> str: 336 | return "".join([f"[{part}]" for part in parts]) 337 | -------------------------------------------------------------------------------- /beetsplug/mapping_model.py: -------------------------------------------------------------------------------- 1 | """`Mapping` Model for Filetote.""" 2 | 3 | from __future__ import annotations 4 | 5 | # Dict is needed for py38 6 | from typing import ClassVar, Dict, Literal 7 | 8 | from beets.dbcore import db 9 | from beets.dbcore import types as db_types 10 | 11 | 12 | class FiletoteMappingModel(db.Model): 13 | """Model for a FiletoteMappingFormatted.""" 14 | 15 | _fields: ClassVar = { 16 | "albumpath": db_types.STRING, 17 | "medianame_old": db_types.STRING, 18 | "medianame_new": db_types.STRING, 19 | "old_filename": db_types.STRING, 20 | "subpath": db_types.STRING, 21 | } 22 | 23 | def set(self, key: str, value: str) -> None: 24 | """Get the formatted version of model[key] as string.""" 25 | super().__setitem__(key, value) 26 | 27 | @classmethod 28 | def _getters(cls) -> Dict[None, None]: 29 | """Return "blank" for getter functions.""" 30 | return {} 31 | 32 | def _template_funcs(self) -> dict[None, None]: 33 | """Return "blank" for template functions.""" 34 | return {} 35 | 36 | 37 | class FiletoteMappingFormatted(db.FormattedMapping): 38 | """Formatted Mapping that does not replace path separators for certain keys 39 | (e.g., `albumpath` and `subpath`), when added to `whitelist_replace`. 40 | """ 41 | 42 | ALL_KEYS: Literal["*"] = "*" 43 | 44 | def __init__( 45 | self, 46 | model: FiletoteMappingModel, 47 | included_keys: Literal["*"] | list[str] = ALL_KEYS, 48 | for_path: bool = False, 49 | whitelist_replace: list[str] | None = None, 50 | ): 51 | """Initializes the formatted Mapping.""" 52 | super().__init__(model, included_keys, for_path) 53 | if whitelist_replace is None: 54 | whitelist_replace = [] 55 | self.whitelist_replace = whitelist_replace 56 | 57 | def __getitem__(self, key: str) -> str: 58 | """Get the formatted version of model[key] as string. Any value 59 | provided in the `whitelist_replace` list will not have the path 60 | separator replaced. 61 | """ 62 | if key in self.whitelist_replace: 63 | value = self.model._type(key).format(self.model.get(key)) # noqa: SLF001 64 | if isinstance(value, bytes): 65 | value = value.decode("utf-8", "ignore") 66 | return str(value) 67 | return str(super().__getitem__(key)) 68 | -------------------------------------------------------------------------------- /beetsplug/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtronset/beets-filetote/a01de20e9036cfb23de0879f3ec7e6f784700eea/beetsplug/py.typed -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | beets-filetote: 3 | container_name: beets-filetote 4 | build: . 5 | command: tail -F anything 6 | restart: unless-stopped 7 | volumes: 8 | - .:/src 9 | - ./example.config.yaml:/root/.config/beets/config.yaml 10 | -------------------------------------------------------------------------------- /example.config.yaml: -------------------------------------------------------------------------------- 1 | plugins: convert filetote 2 | 3 | pluginpath: 4 | - /src/beetsplug 5 | 6 | library: /beets/library.db 7 | 8 | directory: /beets/library 9 | 10 | filetote: 11 | extensions: .* 12 | filename: 13 | - "folder.jpg" 14 | - "Folder.jpg" 15 | - "cover.jpg" 16 | - "Cover.jpg" 17 | print_ignored: true 18 | paths: 19 | pattern:logfolder: $albumpath/Log/$old_filename 20 | pattern:cuefolder: $albumpath/Cue/$old_filename 21 | pattern:logcuefolder: $albumpath/Log & Cue/$old_filename 22 | pattern:scansfolder: $albumpath/Scans/$old_filename.duplicated 23 | ext:7z: $albumpath/Data/$old_filename 24 | ext:log: $albumpath/Data/$catalognum 25 | ext:doc: $albumpath/Data/$old_filename 26 | ext:rtf: $albumpath/Data/$old_filename 27 | ext:html: $albumpath/Data/$old_filename 28 | ext:m3u8: $albumpath/Data/$old_filename 29 | ext:accurip: $albumpath/Data/$old_filename 30 | ext:sha: $albumpath/Data/$old_filename 31 | ext:pls: $albumpath/Data/$old_filename 32 | ext:cue: $albumpath/Data/$catalognum 33 | ext:zip: $albumpath/Data/$old_filename 34 | ext:txt: $albumpath/Data/$old_filename 35 | ext:sfv: $albumpath/Data/$old_filename 36 | ext:iso: $albumpath/Data/$old_filename 37 | ext:nfo: $albumpath/Data/$old_filename 38 | ext:md5: $albumpath/Data/$old_filename 39 | ext:m3u: $albumpath/Data/$old_filename 40 | ext:tif: $albumpath/Scans/$old_filename.imported 41 | ext:jpg: $albumpath/Scans/$old_filename.imported 42 | ext:psd: $albumpath/Scans/$old_filename.imported 43 | ext:png: $albumpath/Scans/$old_filename.imported 44 | ext:bmp: $albumpath/Scans/$old_filename.imported 45 | ext:gif: $albumpath/Scans/$old_filename.imported 46 | ext:pdf: $albumpath/Scans/$old_filename 47 | ext:tiff: $albumpath/Scans/$old_filename.imported 48 | ext:jpeg: $albumpath/Scans/$old_filename.imported 49 | ext:url: $albumpath/Album info 50 | filename:folder.jpg: $albumpath/folder 51 | filename:Folder.jpg: $albumpath/folder 52 | filename:cover.jpg: $albumpath/folder 53 | filename:Cover.jpg: $albumpath/folder 54 | patterns: 55 | logfolder: 56 | - "[lL]og/" 57 | cuefolder: 58 | - "[cC]ue/" 59 | logcuefolder: 60 | - "[lL]og & Cue/" 61 | scansfolder: 62 | - "[sS]cans/" 63 | convert: 64 | auto: yes 65 | format: flac 66 | copy_album_art: yes 67 | delete_originals: no 68 | never_convert_lossy_files: no 69 | quiet: no 70 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | #:schema https://json.schemastore.org/pyproject.json 2 | 3 | [tool.poetry] 4 | name = "beets-filetote" 5 | version = "1.0.3" 6 | description = "A beets plugin to copy/moves non-music extra files, attachments, and artifacts during the import process." 7 | authors = ["Gavin Tronset "] 8 | keywords = ["beets", "files", "artifacts", "extra"] 9 | license = "MIT" 10 | readme = "README.md" 11 | packages = [{ include = "beetsplug" }] 12 | repository = "https://github.com/gtronset/beets-filetote" 13 | documentation = "https://github.com/gtronset/beets-filetote" 14 | classifiers = [ 15 | "Topic :: Multimedia :: Sound/Audio", 16 | "Topic :: Multimedia :: Sound/Audio :: Players :: MP3", 17 | "Environment :: Console", 18 | "Environment :: Web Environment", 19 | ] 20 | 21 | [tool.poetry.urls] 22 | "Release notes" = "https://github.com/gtronset/beets-filetote/releases" 23 | "Source" = "https://github.com/gtronset/beets-filetote" 24 | 25 | [tool.poetry.dependencies] 26 | python = "^3.8" 27 | beets = "^2.0" 28 | mediafile = ">=0.12.0" 29 | typing_extensions = { version = "*", python = "<=3.10" } 30 | 31 | [tool.poetry.group.dev.dependencies] 32 | beets-audible = ">=0.1.0" 33 | reflink = ">=0.2.1" 34 | toml = ">=0.10.2" 35 | 36 | [tool.poetry.group.lint.dependencies] 37 | mypy = [ 38 | { version = "^1.15.0", python = ">=3.9" }, 39 | { version = "^1.9.0", python = ">=3.8,<3.9" }, 40 | ] 41 | ruff = "^0.11.8" 42 | 43 | [tool.poetry.group.test.dependencies] 44 | pytest = "^8.0.0" 45 | typeguard = "^4.4.0" 46 | 47 | [build-system] 48 | requires = ["poetry-core<2.0.0"] 49 | build-backend = "poetry.core.masonry.api" 50 | 51 | [tool.mypy] 52 | mypy_path = ["./typehints"] 53 | modules = ["beetsplug", "tests.helper", "tests._common"] 54 | strict = true 55 | pretty = true 56 | 57 | [tool.pre-commit-update] 58 | dry_run = false 59 | all_versions = false 60 | verbose = true 61 | warnings = true 62 | preview = false 63 | jobs = 10 64 | keep = ["poetry"] 65 | 66 | [tool.pre-commit-update.yaml] 67 | mapping = 2 68 | sequence = 6 69 | offset = 4 70 | 71 | [tool.pytest.ini_options] 72 | testpaths = ["./tests"] 73 | filterwarnings = ["ignore::DeprecationWarning:.*confuse"] 74 | 75 | [tool.ruff] 76 | line-length = 88 77 | # namespace-packages = ["beetsplug"] 78 | preview = true 79 | src = ["beetsplug", "tests", "typehints"] 80 | target-version = "py38" 81 | 82 | [tool.ruff.lint] 83 | ignore = ["D205", "PLR6301"] 84 | extend-select = [ 85 | "A", # flake8-builtins 86 | "ARG", # flake8-unused-arguments 87 | "C4", # flake8-comprehensions 88 | "D", # pydocstyle 89 | "E", # pycodestyle 90 | "F", # pyflakes 91 | "FA", # flake8-future-annotations 92 | "B", # flake8-bugbear 93 | "I", # isort 94 | "N", # pep8-naming 95 | "PERF", # perflint 96 | "PL", # pylint 97 | "PT", # flake8-pytest-style 98 | "PYI", # flake8-pyi 99 | "RUF", # ruff 100 | "SIM", # flake8-simplify 101 | "SLF", # flake8-self 102 | "TD", # flake8-todos 103 | "TC", # flake8-type-checking 104 | "UP", # pyupgrade 105 | "W", # pycodestyle 106 | # "PTH", # flake8-use-pathlib 107 | ] 108 | 109 | [tool.ruff.lint.flake8-pytest-style] 110 | fixture-parentheses = false 111 | mark-parentheses = false 112 | parametrize-names-type = "csv" 113 | 114 | [tool.ruff.lint.flake8-unused-arguments] 115 | ignore-variadic-names = true 116 | 117 | [tool.ruff.lint.isort] 118 | lines-between-types = 1 119 | known-local-folder = ["tests"] 120 | 121 | [tool.ruff.lint.pep8-naming] 122 | extend-ignore-names = ["assert*", "getLogger"] 123 | 124 | [tool.ruff.lint.per-file-ignores] 125 | "beetsplug/**" = ["PT"] 126 | "tests/**" = ["PLR0917"] 127 | 128 | [tool.ruff.lint.pycodestyle] 129 | max-line-length = 88 130 | 131 | [tool.ruff.lint.pydocstyle] 132 | # Use Google-style docstrings. 133 | convention = "google" 134 | 135 | [tool.ruff.lint.pyupgrade] 136 | # Preserve types, even if a file imports `from __future__ import annotations`. 137 | keep-runtime-typing = true 138 | 139 | [tool.tox] 140 | requires = ["tox>=4.22"] 141 | min_version = "4.22" 142 | env_list = ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 143 | 144 | [tool.tox.env_run_base] 145 | description = "Run test under {base_python}" 146 | allowlist_externals = ["poetry", "pytest"] 147 | commands_pre = [["poetry", "install", "--no-root", "--sync"]] 148 | commands = [ 149 | [ 150 | "poetry", 151 | "run", 152 | "pytest", 153 | "tests", 154 | "--typeguard-packages=beetsplug", 155 | ], 156 | ] 157 | 158 | [tool.tox.env.lint] 159 | description = "Lint source code" 160 | commands = [["poetry", "run", "ruff", "check"]] 161 | 162 | [tool.tox.env.lint-fix] 163 | description = "Fixes and lint issues in source code" 164 | commands = [["poetry", "run", "ruff", "check", "--fix"]] 165 | 166 | 167 | [tool.tox.env.format] 168 | description = "Formats source code" 169 | commands = [["poetry", "run", "ruff", "format"]] 170 | 171 | [tool.tox.env.mypy] 172 | description = "Checks types in source code" 173 | commands = [["poetry", "run", "mypy"]] 174 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Setup for beets-filetote.""" 3 | 4 | from setuptools import setup 5 | 6 | if __name__ == "__main__": 7 | setup( 8 | py_modules=["beetsplug"], 9 | ) 10 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for the beets-filetote plugin.""" 2 | -------------------------------------------------------------------------------- /tests/_common.py: -------------------------------------------------------------------------------- 1 | """Setup for tests for the beets-filetote plugin.""" 2 | 3 | import os 4 | import shutil 5 | import sys 6 | import tempfile 7 | import unittest 8 | 9 | from typing import List, Optional 10 | 11 | import reflink 12 | 13 | from beets import config, logging, util 14 | 15 | # Test resources path. 16 | RSRC = util.bytestring_path(os.path.join(os.path.dirname(__file__), "rsrc")) 17 | 18 | # Propagate to root logger so nosetest can capture it 19 | log = logging.getLogger("beets") 20 | log.propagate = True 21 | log.setLevel(logging.DEBUG) 22 | 23 | PLATFORM = sys.platform 24 | 25 | # OS feature test. 26 | HAVE_SYMLINK = PLATFORM != "win32" 27 | HAVE_HARDLINK = PLATFORM != "win32" 28 | HAVE_REFLINK = reflink.supported_at(tempfile.gettempdir()) 29 | 30 | 31 | class AssertionsMixin: 32 | """A mixin with additional unit test assertions.""" 33 | 34 | assertions = unittest.TestCase() 35 | 36 | def assert_exists(self, path: bytes) -> None: 37 | """Assertion that a file exists.""" 38 | assert os.path.exists(util.syspath(path)), f"file does not exist: {path!r}" 39 | 40 | def assert_does_not_exist(self, path: bytes) -> None: 41 | """Assertion that a file does not exists.""" 42 | assert not os.path.exists(util.syspath(path)), f"file exists: {path!r}" 43 | 44 | def assert_equal_path(self, path_a: bytes, path_b: bytes) -> None: 45 | """Check that two paths are equal.""" 46 | assert util.normpath(path_a) == util.normpath(path_b), ( 47 | f"paths are not equal: {path_a!r} and {path_b!r}" 48 | ) 49 | 50 | 51 | # A test harness for all beets tests. 52 | # Provides temporary, isolated configuration. 53 | class TestCase(unittest.TestCase): 54 | """A unittest.TestCase subclass that saves and restores beets' 55 | global configuration. This allows tests to make temporary 56 | modifications that will then be automatically removed when the test 57 | completes. Also provides some additional assertion methods, a 58 | temporary directory, and a DummyIO. 59 | """ 60 | 61 | def setUp(self) -> None: 62 | # A "clean" source list including only the defaults. 63 | config.sources = [] 64 | config.read(user=False, defaults=True) 65 | 66 | # Direct paths to a temporary directory. Tests can also use this 67 | # temporary directory. 68 | self.temp_dir = util.bytestring_path(tempfile.mkdtemp()) 69 | 70 | config["statefile"] = os.fsdecode(os.path.join(self.temp_dir, b"state.pickle")) 71 | config["library"] = os.fsdecode(os.path.join(self.temp_dir, b"library.db")) 72 | config["directory"] = os.fsdecode(os.path.join(self.temp_dir, b"libdir")) 73 | 74 | # Set $HOME, which is used by confit's `config_dir()` to create 75 | # directories. 76 | self._old_home = os.environ.get("HOME") 77 | os.environ["HOME"] = os.fsdecode(self.temp_dir) 78 | 79 | # Initialize, but don't install, a DummyIO. 80 | self.in_out = DummyIO() 81 | 82 | def tearDown(self) -> None: 83 | if os.path.isdir(self.temp_dir): 84 | shutil.rmtree(self.temp_dir) 85 | if self._old_home is None: 86 | del os.environ["HOME"] 87 | else: 88 | os.environ["HOME"] = self._old_home 89 | self.in_out.restore() 90 | 91 | config.clear() 92 | 93 | 94 | # Mock I/O. 95 | 96 | 97 | class InputError(Exception): 98 | """Provides handling of input exceptions.""" 99 | 100 | def __init__(self, output: Optional[str] = None) -> None: 101 | self.output = output 102 | 103 | def __str__(self) -> str: 104 | msg = "Attempt to read with no input provided." 105 | if self.output is not None: 106 | msg += f" Output: {self.output!r}" 107 | return msg 108 | 109 | 110 | class DummyOut: 111 | """Provides fake/"dummy" output handling.""" 112 | 113 | encoding = "utf-8" 114 | 115 | def __init__(self) -> None: 116 | self.buf: List[str] = [] 117 | 118 | def write(self, buf_item: str) -> None: 119 | """Writes to buffer.""" 120 | self.buf.append(buf_item) 121 | 122 | def get(self) -> str: 123 | """Get from buffer.""" 124 | return "".join(self.buf) 125 | 126 | def flush(self) -> None: 127 | """Flushes/clears output.""" 128 | self.clear() 129 | 130 | def clear(self) -> None: 131 | """Resets buffer.""" 132 | self.buf = [] 133 | 134 | 135 | class DummyIn: 136 | """Provides fake/"dummy" input handling.""" 137 | 138 | encoding = "utf-8" 139 | 140 | def __init__(self, out: Optional[DummyOut] = None) -> None: 141 | self.buf: List[str] = [] 142 | self.reads: int = 0 143 | self.out: Optional[DummyOut] = out 144 | 145 | def add(self, buf_item: str) -> None: 146 | """Add buffer input.""" 147 | self.buf.append(buf_item + "\n") 148 | 149 | def readline(self) -> str: 150 | """Reads buffer line.""" 151 | if not self.buf: 152 | if self.out: 153 | raise InputError(self.out.get()) 154 | 155 | raise InputError() 156 | self.reads += 1 157 | return self.buf.pop(0) 158 | 159 | 160 | class DummyIO: 161 | """Mocks input and output streams for testing UI code.""" 162 | 163 | def __init__(self) -> None: 164 | self.stdout: DummyOut = DummyOut() 165 | self.stdin: DummyIn = DummyIn(self.stdout) 166 | 167 | def addinput(self, inputs: str) -> None: 168 | """Adds IO input.""" 169 | self.stdin.add(inputs) 170 | 171 | def getoutput(self) -> str: 172 | """Gets IO output.""" 173 | res = self.stdout.get() 174 | self.stdout.clear() 175 | return res 176 | 177 | def readcount(self) -> int: 178 | """Reads from stdin.""" 179 | return self.stdin.reads 180 | 181 | def install(self) -> None: 182 | """Setup stdin and stdout.""" 183 | sys.stdin = self.stdin 184 | sys.stdout = self.stdout 185 | 186 | def restore(self) -> None: 187 | """Restores/reset both stdin and stdout.""" 188 | sys.stdin = sys.__stdin__ 189 | sys.stdout = sys.__stdout__ 190 | -------------------------------------------------------------------------------- /tests/_item_model.py: -------------------------------------------------------------------------------- 1 | """Item Model for Filetote tests.""" 2 | 3 | from dataclasses import dataclass 4 | 5 | 6 | @dataclass 7 | class MediaMeta: 8 | """Metadata for created media files. 9 | 10 | Follows typing from the [Beets Item](https://github.com/beetbox/beets/blob/v2.2.0/beets/library.py#L511-L604) 11 | """ 12 | 13 | # TODO(gtronset): Update types for beets v2: 14 | # https://github.com/beetbox/beets/blob/v2.2.0/beets/library.py#L506 15 | 16 | title: str = "Tag Title 1" 17 | artist: str = "Tag Artist" 18 | albumartist: str = "Tag Album Artist" 19 | album: str = "Tag Album" 20 | genre: str = "Tag genre" 21 | lyricist: str = "Tag lyricist" 22 | composer: str = "Tag composer" 23 | arranger: str = "Tag arranger" 24 | grouping: str = "Tag grouping" 25 | work: str = "Tag work title" 26 | mb_workid: str = "Tag work musicbrainz id" 27 | work_disambig: str = "Tag work disambiguation" 28 | year: int = 2023 29 | month: int = 2 30 | day: int = 3 31 | track: int = 1 32 | tracktotal: int = 5 33 | disc: int = 1 34 | disctotal: int = 7 35 | lyrics: str = "Tag lyrics" 36 | comments: str = "Tag comments" 37 | bpm: int = 8 38 | comp: bool = True 39 | mb_trackid: str = "someID-1" 40 | mb_albumid: str = "someID-2" 41 | mb_artistid: str = "someID-3" 42 | mb_albumartistid: str = "someID-4" 43 | mb_releasetrackid: str = "someID-5" 44 | -------------------------------------------------------------------------------- /tests/rsrc/full.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtronset/beets-filetote/a01de20e9036cfb23de0879f3ec7e6f784700eea/tests/rsrc/full.flac -------------------------------------------------------------------------------- /tests/rsrc/full.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtronset/beets-filetote/a01de20e9036cfb23de0879f3ec7e6f784700eea/tests/rsrc/full.mp3 -------------------------------------------------------------------------------- /tests/rsrc/full.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gtronset/beets-filetote/a01de20e9036cfb23de0879f3ec7e6f784700eea/tests/rsrc/full.wav -------------------------------------------------------------------------------- /tests/test_audible_m4b_files.py: -------------------------------------------------------------------------------- 1 | """Tests that m4b music/audiobook files are ignored for the beets-filetote 2 | plugin, when the beets-audible plugin is loaded. 3 | """ 4 | 5 | import logging 6 | 7 | from typing import List, Optional 8 | 9 | from beets import config 10 | 11 | from tests.helper import FiletoteTestCase, MediaSetup 12 | 13 | log = logging.getLogger("beets") 14 | 15 | 16 | class FiletoteM4BFilesIgnoredTest(FiletoteTestCase): 17 | """Tests to check that Filetote does not copy music/audiobook files when the 18 | beets-audible plugin is present. 19 | """ 20 | 21 | def setUp(self, _other_plugins: Optional[List[str]] = None) -> None: 22 | """Provides shared setup for tests.""" 23 | super().setUp(other_plugins=["audible", "inline"]) 24 | 25 | def test_expanded_music_file_types_are_ignored(self) -> None: 26 | """Ensure that `.m4b` file types are ignored by Filetote.""" 27 | self._create_flat_import_dir(media_files=[MediaSetup(file_type="m4b", count=1)]) 28 | self._setup_import_session(autotag=False) 29 | 30 | config["filetote"]["extensions"] = ".*" 31 | 32 | self._run_cli_command("import") 33 | 34 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.m4b") 35 | -------------------------------------------------------------------------------- /tests/test_cli_operation.py: -------------------------------------------------------------------------------- 1 | """Tests CLI operations supersede config for the beets-filetote plugin.""" 2 | 3 | import os 4 | 5 | from typing import List, Optional 6 | 7 | import beets 8 | 9 | from beets import config 10 | 11 | from tests.helper import FiletoteTestCase 12 | 13 | 14 | class FiletoteCLIOperation(FiletoteTestCase): 15 | """Tests to check handling of the operation (copy, move, etc.) can be 16 | overridden by the CLI. 17 | """ 18 | 19 | def setUp(self, _other_plugins: Optional[List[str]] = None) -> None: 20 | """Provides shared setup for tests.""" 21 | super().setUp() 22 | 23 | self._set_import_dir() 24 | self.album_path = os.path.join(self.import_dir, b"the_album") 25 | self.rsrc_mp3 = b"full.mp3" 26 | os.makedirs(self.album_path) 27 | 28 | self._base_file_count: int = 0 29 | 30 | config["filetote"]["extensions"] = ".file" 31 | 32 | def test_do_nothing_when_not_copying_or_moving(self) -> None: 33 | """Check that plugin leaves everything alone when not 34 | copying (-C command line option) and not moving. 35 | """ 36 | self._create_flat_import_dir() 37 | self._setup_import_session(autotag=False) 38 | 39 | self._base_file_count = self._media_count + self._pairs_count 40 | 41 | config["import"]["copy"] = False 42 | config["import"]["move"] = False 43 | 44 | self._run_cli_command("import") 45 | 46 | self.assert_number_of_files_in_dir( 47 | self._base_file_count + 4, self.import_dir, b"the_album" 48 | ) 49 | 50 | self.assert_in_import_dir(b"the_album", b"artifact.file") 51 | self.assert_in_import_dir(b"the_album", b"artifact2.file") 52 | self.assert_in_import_dir(b"the_album", b"artifact.nfo") 53 | self.assert_in_import_dir(b"the_album", b"artifact.lrc") 54 | 55 | def test_import_config_copy_false_import_on_copy(self) -> None: 56 | """Tests that when config does not have an operation set, that 57 | providing it as `--copy` in the CLI correctly overrides. 58 | """ 59 | self._setup_import_session(copy=False, autotag=False) 60 | 61 | self.create_file( 62 | self.album_path, beets.util.bytestring_path("\xe4rtifact.file") 63 | ) 64 | medium = self._create_medium( 65 | os.path.join(self.album_path, b"track_1.mp3"), self.rsrc_mp3 66 | ) 67 | self.import_media = [medium] 68 | 69 | self._run_cli_command("import", operation_option="copy") 70 | 71 | self.assert_in_import_dir( 72 | b"the_album", 73 | beets.util.bytestring_path("\xe4rtifact.file"), 74 | ) 75 | 76 | self.assert_in_lib_dir( 77 | b"Tag Artist", 78 | b"Tag Album", 79 | beets.util.bytestring_path("\xe4rtifact.file"), 80 | ) 81 | 82 | def test_import_config_copy_false_import_on_move(self) -> None: 83 | """Tests that when config does not have an operation set, that 84 | providing it as `--move` in the CLI correctly overrides. 85 | """ 86 | self._setup_import_session(copy=False, autotag=False) 87 | 88 | self.create_file( 89 | self.album_path, beets.util.bytestring_path("\xe4rtifact.file") 90 | ) 91 | medium = self._create_medium( 92 | os.path.join(self.album_path, b"track_1.mp3"), self.rsrc_mp3 93 | ) 94 | self.import_media = [medium] 95 | 96 | self._run_cli_command("import", operation_option="move") 97 | 98 | self.assert_not_in_import_dir( 99 | b"the_album", 100 | beets.util.bytestring_path("\xe4rtifact.file"), 101 | ) 102 | 103 | self.assert_in_lib_dir( 104 | b"Tag Artist", 105 | b"Tag Album", 106 | beets.util.bytestring_path("\xe4rtifact.file"), 107 | ) 108 | 109 | def test_import_config_copy_true_import_on_move(self) -> None: 110 | """Tests that when config operation is set to `copy`, that providing 111 | `--move` in the CLI correctly overrides. 112 | """ 113 | self._setup_import_session(copy=True, autotag=False) 114 | 115 | self.create_file( 116 | self.album_path, beets.util.bytestring_path("\xe4rtifact.file") 117 | ) 118 | medium = self._create_medium( 119 | os.path.join(self.album_path, b"track_1.mp3"), self.rsrc_mp3 120 | ) 121 | self.import_media = [medium] 122 | 123 | self._run_cli_command("import", operation_option="move") 124 | 125 | self.assert_not_in_import_dir( 126 | b"the_album", 127 | beets.util.bytestring_path("\xe4rtifact.file"), 128 | ) 129 | 130 | self.assert_in_lib_dir( 131 | b"Tag Artist", 132 | b"Tag Album", 133 | beets.util.bytestring_path("\xe4rtifact.file"), 134 | ) 135 | 136 | def test_import_config_move_true_import_on_copy(self) -> None: 137 | """Tests that when config operation is set to `move`, that providing 138 | `--copy` in the CLI correctly overrides. 139 | """ 140 | self._setup_import_session(move=True, autotag=False) 141 | 142 | self.create_file( 143 | self.album_path, beets.util.bytestring_path("\xe4rtifact.file") 144 | ) 145 | medium = self._create_medium( 146 | os.path.join(self.album_path, b"track_1.mp3"), self.rsrc_mp3 147 | ) 148 | self.import_media = [medium] 149 | 150 | self._run_cli_command("import", operation_option="copy") 151 | 152 | self.assert_in_import_dir( 153 | b"the_album", 154 | beets.util.bytestring_path("\xe4rtifact.file"), 155 | ) 156 | 157 | self.assert_in_lib_dir( 158 | b"Tag Artist", 159 | b"Tag Album", 160 | beets.util.bytestring_path("\xe4rtifact.file"), 161 | ) 162 | 163 | def test_move_on_move_command(self) -> None: 164 | """Check that plugin detects the correct operation for the "move" (or "mv") 165 | command, which is MOVE by default. 166 | """ 167 | self._create_flat_import_dir() 168 | 169 | self._setup_import_session(move=True, autotag=False) 170 | 171 | self.lib.path_formats = [ 172 | ("default", os.path.join("Old Lib Artist", "$album", "$title")), 173 | ] 174 | 175 | self._run_cli_command("import") 176 | 177 | self.lib.path_formats = [ 178 | ("default", os.path.join("$artist", "$album", "$title")), 179 | ] 180 | 181 | self._run_cli_command("move", query="artist:'Tag Artist'") 182 | 183 | self.assert_not_in_lib_dir( 184 | b"Old Lib Artist", 185 | b"Tag Album", 186 | b"artifact.file", 187 | ) 188 | 189 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 190 | 191 | def test_copy_on_move_command_copy(self) -> None: 192 | """Check that plugin detects the correct operation for the "move" (or "mv") 193 | command when "copy" is set. The files should be present in both the original 194 | and new Library locations. 195 | """ 196 | self._create_flat_import_dir() 197 | 198 | self._setup_import_session(move=True, autotag=False) 199 | 200 | self.lib.path_formats = [ 201 | ("default", os.path.join("Old Lib Artist", "$album", "$title")), 202 | ] 203 | 204 | self._run_cli_command("import") 205 | 206 | self.lib.path_formats = [ 207 | ("default", os.path.join("$artist", "$album", "$title")), 208 | ] 209 | 210 | self._run_cli_command("move", query="artist:'Tag Artist'", copy=True) 211 | 212 | self.assert_in_lib_dir(b"Old Lib Artist", b"Tag Album", b"artifact.file") 213 | 214 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 215 | 216 | def test_copy_on_move_command_export(self) -> None: 217 | """Check that plugin detects the correct operation for the "move" (or "mv") 218 | command when "export" is set. This functionally is the same as "copy" but 219 | does not alter the Library data. 220 | """ 221 | self._create_flat_import_dir() 222 | 223 | self._setup_import_session(move=True, autotag=False) 224 | 225 | self.lib.path_formats = [ 226 | ("default", os.path.join("Old Lib Artist", "$album", "$title")), 227 | ] 228 | 229 | self._run_cli_command("import") 230 | 231 | self.lib.path_formats = [ 232 | ("default", os.path.join("$artist", "$album", "$title")), 233 | ] 234 | 235 | self._run_cli_command("move", query="artist:'Tag Artist'", export=True) 236 | 237 | self.assert_in_lib_dir(b"Old Lib Artist", b"Tag Album", b"artifact.file") 238 | 239 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 240 | 241 | def test_move_on_modify_command(self) -> None: 242 | """Check that plugin detects the correct operation for the "move" (or "mv") 243 | command, which is MOVE by default. 244 | """ 245 | self._create_flat_import_dir() 246 | 247 | self._setup_import_session(move=True, autotag=False) 248 | 249 | self.lib.path_formats = [ 250 | ("default", os.path.join("Old Lib Artist", "$album", "$title")), 251 | ] 252 | 253 | self._run_cli_command("import") 254 | 255 | self.lib.path_formats = [ 256 | ("default", os.path.join("$artist", "$album", "$title")), 257 | ] 258 | 259 | self._run_cli_command( 260 | "modify", query="artist:'Tag Artist'", mods={"artist": "Tag Artist New"} 261 | ) 262 | 263 | self.assert_not_in_lib_dir( 264 | b"Old Lib Artist", 265 | b"Tag Album", 266 | b"artifact.file", 267 | ) 268 | 269 | self.assert_in_lib_dir(b"Tag Artist New", b"Tag Album", b"artifact.file") 270 | 271 | def test_move_on_update_move_command(self) -> None: 272 | """Check that plugin detects the correct operation for the "update" 273 | command, which will MOVE by default. 274 | """ 275 | self._create_flat_import_dir() 276 | 277 | self._setup_import_session(move=True, autotag=False) 278 | 279 | self._run_cli_command("import") 280 | 281 | self._update_medium( 282 | path=os.path.join( 283 | self.lib_dir, b"Tag Artist", b"Tag Album", b"Tag Title 1.mp3" 284 | ), 285 | meta_updates={"artist": "New Artist Updated"}, 286 | ) 287 | 288 | self._run_cli_command( 289 | "update", query="artist:'Tag Artist'", fields=["artist"], move=True 290 | ) 291 | 292 | self.assert_not_in_lib_dir( 293 | b"Tag Artist", 294 | b"Tag Album", 295 | b"artifact.file", 296 | ) 297 | 298 | self.assert_in_lib_dir(b"New Artist Updated", b"Tag Album", b"artifact.file") 299 | 300 | def test_pairs_on_update_move_command(self) -> None: 301 | """Check that plugin handles "pairs" for the "update" 302 | command, which will MOVE by default. 303 | """ 304 | self._create_flat_import_dir() 305 | 306 | self._setup_import_session(move=True, autotag=False) 307 | 308 | config["filetote"]["extensions"] = ".lrc" 309 | config["filetote"]["pairing"] = { 310 | "enabled": True, 311 | "pairing_only": True, 312 | "extensions": ".lrc", 313 | } 314 | 315 | config["paths"]["paired_ext:.lrc"] = "$albumpath/$medianame_new" 316 | 317 | self.lib.path_formats = [ 318 | ( 319 | "default", 320 | os.path.join("$artist", "$album", "$album - $track - $artist - $title"), 321 | ), 322 | ] 323 | 324 | self._run_cli_command("import") 325 | 326 | self._update_medium( 327 | path=os.path.join( 328 | self.lib_dir, 329 | b"Tag Artist", 330 | b"Tag Album", 331 | b"Tag Album - 01 - Tag Artist - Tag Title 1.mp3", 332 | ), 333 | meta_updates={"artist": "New Artist Updated"}, 334 | ) 335 | 336 | self._run_cli_command( 337 | "update", query="artist:'Tag Artist'", fields=["artist"], move=True 338 | ) 339 | 340 | self.assert_not_in_lib_dir( 341 | b"Tag Artist", 342 | b"Tag Album", 343 | b"artifact.file", 344 | ) 345 | 346 | self.assert_in_lib_dir( 347 | b"New Artist Updated", 348 | b"Tag Album", 349 | b"Tag Album - 01 - New Artist Updated - Tag Title 1.lrc", 350 | ) 351 | -------------------------------------------------------------------------------- /tests/test_exclude.py: -------------------------------------------------------------------------------- 1 | """Tests to ensure the `exclude` settings properly excludes files in the beets-filetote 2 | plugin. 3 | """ 4 | 5 | import os 6 | 7 | from typing import List, Optional 8 | 9 | import beets 10 | 11 | from beets import config 12 | 13 | from tests.helper import FiletoteTestCase, capture_log 14 | 15 | 16 | class FiletoteExcludeTest(FiletoteTestCase): 17 | """Tests to ensure the `exclude` settings properly excludes files in the 18 | beets-filetote plugin. 19 | """ 20 | 21 | def setUp(self, _other_plugins: Optional[List[str]] = None) -> None: 22 | """Provides shared setup for tests.""" 23 | super().setUp() 24 | 25 | self._create_flat_import_dir() 26 | 27 | self.album_path = os.path.join(self.import_dir, b"the_album") 28 | 29 | self._setup_import_session(move=True, autotag=False) 30 | 31 | def test_exclude_strseq_of_filenames_by_string(self) -> None: 32 | """Tests to ensure the `exclude` config registers as a strseq (string 33 | sequence) of filenames. 34 | """ 35 | config["filetote"]["extensions"] = ".file .lrc" 36 | config["filetote"]["exclude"] = "not_to_be_moved.file not_to_be_moved.lrc" 37 | config["paths"]["ext:file"] = "$albumpath/$old_filename" 38 | 39 | self.create_file( 40 | self.album_path, beets.util.bytestring_path("not_to_be_moved.file") 41 | ) 42 | 43 | self.create_file( 44 | self.album_path, beets.util.bytestring_path("not_to_be_moved.lrc") 45 | ) 46 | 47 | with capture_log() as logs: 48 | self._run_cli_command("import") 49 | 50 | self.assert_in_import_dir( 51 | b"the_album", 52 | b"not_to_be_moved.file", 53 | ) 54 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"not_to_be_moved.file") 55 | 56 | self.assert_in_import_dir( 57 | b"the_album", 58 | b"not_to_be_moved.lrc", 59 | ) 60 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"not_to_be_moved.lrc") 61 | 62 | # Ensure the deprecation warning is present 63 | logs = [line for line in logs if line.startswith("filetote:")] 64 | assert logs == [ 65 | ( 66 | "filetote: Deprecation warning: The `exclude` plugin should now use the" 67 | " explicit settings of `filenames`, `extensions`, and/or `patterns`." 68 | " See the `exclude` documentation for more details:" 69 | " https://github.com/gtronset/beets-filetote#excluding-files" 70 | ) 71 | ] 72 | 73 | def test_exclude_strseq_of_filenames_by_list(self) -> None: 74 | """Tests to ensure the `exclude` config registers as a strseq (string 75 | sequence) of filenames. 76 | """ 77 | config["filetote"]["extensions"] = ".file .lrc" 78 | config["filetote"]["exclude"] = ["not_to_be_moved.file", "not_to_be_moved.lrc"] 79 | config["paths"]["ext:file"] = "$albumpath/$old_filename" 80 | 81 | self.create_file( 82 | self.album_path, beets.util.bytestring_path("not_to_be_moved.file") 83 | ) 84 | 85 | self.create_file( 86 | self.album_path, beets.util.bytestring_path("not_to_be_moved.lrc") 87 | ) 88 | 89 | with capture_log() as logs: 90 | self._run_cli_command("import") 91 | 92 | self.assert_in_import_dir( 93 | b"the_album", 94 | b"not_to_be_moved.file", 95 | ) 96 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"not_to_be_moved.file") 97 | 98 | self.assert_in_import_dir( 99 | b"the_album", 100 | b"not_to_be_moved.lrc", 101 | ) 102 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"not_to_be_moved.lrc") 103 | 104 | # Ensure the deprecation warning is present 105 | logs = [line for line in logs if line.startswith("filetote:")] 106 | assert logs == [ 107 | ( 108 | "filetote: Deprecation warning: The `exclude` plugin should now use the" 109 | " explicit settings of `filenames`, `extensions`, and/or `patterns`." 110 | " See the `exclude` documentation for more details:" 111 | " https://github.com/gtronset/beets-filetote#excluding-files" 112 | ) 113 | ] 114 | 115 | def test_exclude_dict_with_filenames_extensions(self) -> None: 116 | """Tests to ensure the `exclude` config registers dictionary of `filenames` 117 | and/or `extensions`. 118 | """ 119 | config["filetote"]["extensions"] = ".*" 120 | 121 | config["filetote"]["exclude"] = { 122 | "filenames": ["not_to_be_moved.file"], 123 | "extensions": [".lrc"], 124 | } 125 | 126 | self.create_file( 127 | self.album_path, beets.util.bytestring_path("not_to_be_moved.file") 128 | ) 129 | 130 | self.create_file( 131 | self.album_path, beets.util.bytestring_path("not_to_be_moved.lrc") 132 | ) 133 | 134 | self._run_cli_command("import") 135 | 136 | self.assert_in_import_dir( 137 | b"the_album", 138 | b"not_to_be_moved.file", 139 | ) 140 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"not_to_be_moved.file") 141 | 142 | self.assert_in_import_dir( 143 | b"the_album", 144 | b"not_to_be_moved.lrc", 145 | ) 146 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"not_to_be_moved.lrc") 147 | 148 | def test_exclude_dict_with_patterns(self) -> None: 149 | """Tests to ensure the `exclude` config and works with and patterns.""" 150 | config["filetote"]["extensions"] = ".*" 151 | 152 | config["filetote"]["exclude"]["patterns"] = { 153 | "file-pattern": ["[aA]rtifact.*"], 154 | "nfo-pattern": ["*.lrc"], 155 | } 156 | 157 | self.create_file( 158 | self.album_path, beets.util.bytestring_path("to_be_moved.file") 159 | ) 160 | 161 | self.create_file( 162 | self.album_path, beets.util.bytestring_path("not_to_be_moved.lrc") 163 | ) 164 | 165 | self._run_cli_command("import") 166 | 167 | self.assert_in_lib_dir( 168 | b"Tag Artist", 169 | b"Tag Album", 170 | b"to_be_moved.file", 171 | ) 172 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 173 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"not_to_be_moved.lrc") 174 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.lrc") 175 | -------------------------------------------------------------------------------- /tests/test_filename.py: -------------------------------------------------------------------------------- 1 | """Tests file-naming for the beets-filetote plugin.""" 2 | 3 | import os 4 | import re 5 | 6 | from typing import List, Optional 7 | 8 | import pytest 9 | 10 | import beets 11 | 12 | from beets import config 13 | 14 | from ._item_model import MediaMeta 15 | from tests import _common 16 | from tests.helper import FiletoteTestCase 17 | 18 | 19 | class FiletoteFilename(FiletoteTestCase): 20 | """Tests to check handling of artifacts with filenames containing unicode 21 | characters. 22 | """ 23 | 24 | def setUp(self, _other_plugins: Optional[List[str]] = None) -> None: 25 | """Provides shared setup for tests.""" 26 | super().setUp() 27 | 28 | self._set_import_dir() 29 | self.album_path = os.path.join(self.import_dir, b"the_album") 30 | self.rsrc_mp3 = b"full.mp3" 31 | os.makedirs(self.album_path) 32 | 33 | self._setup_import_session(autotag=False) 34 | 35 | config["filetote"]["extensions"] = ".file" 36 | 37 | def test_import_dir_with_unicode_character_in_artifact_name_copy(self) -> None: 38 | """Tests that unicode characters copy as expected.""" 39 | self.create_file( 40 | self.album_path, beets.util.bytestring_path("\xe4rtifact.file") 41 | ) 42 | medium = self._create_medium( 43 | os.path.join(self.album_path, b"track_1.mp3"), self.rsrc_mp3 44 | ) 45 | self.import_media = [medium] 46 | 47 | self._run_cli_command("import") 48 | 49 | self.assert_in_lib_dir( 50 | b"Tag Artist", 51 | b"Tag Album", 52 | beets.util.bytestring_path("\xe4rtifact.file"), 53 | ) 54 | 55 | def test_import_dir_with_unicode_character_in_artifact_name_move(self) -> None: 56 | """Tests that unicode characters move as expected.""" 57 | config["import"]["move"] = True 58 | 59 | self.create_file( 60 | self.album_path, beets.util.bytestring_path("\xe4rtifact.file") 61 | ) 62 | medium = self._create_medium( 63 | os.path.join(self.album_path, b"track_1.mp3"), self.rsrc_mp3 64 | ) 65 | self.import_media = [medium] 66 | 67 | self._run_cli_command("import") 68 | 69 | self.assert_in_lib_dir( 70 | b"Tag Artist", 71 | b"Tag Album", 72 | beets.util.bytestring_path("\xe4rtifact.file"), 73 | ) 74 | 75 | @pytest.mark.skipif(_common.PLATFORM == "win32", reason="win32") 76 | def test_import_with_illegal_character_in_artifact_name_obeys_beets( 77 | self, 78 | ) -> None: 79 | """Tests that illegal characters in file name are replaced following beets 80 | conventions. This is skipped in Windows as the characters used here are 81 | not allowed. 82 | """ 83 | config["import"]["move"] = True 84 | config["filetote"]["extensions"] = ".log" 85 | config["paths"]["ext:.log"] = "$albumpath/$album - $old_filename" 86 | 87 | self.lib.path_formats[0] = ( 88 | "default", 89 | os.path.join("$artist", "$album", "$album - $title"), 90 | ) 91 | 92 | self.create_file( 93 | self.album_path, 94 | b"CoolName: Album&Tag.log", 95 | ) 96 | medium = self._create_medium( 97 | os.path.join(self.album_path, b"track_1.mp3"), 98 | self.rsrc_mp3, 99 | MediaMeta(album="Album: Subtitle"), 100 | ) 101 | self.import_media = [medium] 102 | 103 | self._run_cli_command("import") 104 | 105 | self.assert_in_lib_dir( 106 | b"Tag Artist", 107 | b"Album_ Subtitle", 108 | beets.util.bytestring_path("Album_ Subtitle - CoolName_ Album&Tag.log"), 109 | ) 110 | 111 | def test_import_dir_with_illegal_character_in_album_name(self) -> None: 112 | """Tests that illegal characters in album name are replaced following beets 113 | conventions. 114 | """ 115 | config["paths"]["ext:file"] = "$albumpath/$artist - $album" 116 | 117 | # Create import directory, illegal filename character used in the album name 118 | self.create_file(self.album_path, b"artifact.file") 119 | medium = self._create_medium( 120 | os.path.join(self.album_path, b"track_1.mp3"), 121 | self.rsrc_mp3, 122 | MediaMeta(album="Tag Album?"), 123 | ) 124 | self.import_media = [medium] 125 | 126 | self._run_cli_command("import") 127 | 128 | self.assert_in_lib_dir( 129 | b"Tag Artist", b"Tag Album_", b"Tag Artist - Tag Album_.file" 130 | ) 131 | 132 | def test_rename_works_with_custom_replace(self) -> None: 133 | """Tests that custom "replace" settings work as expected.""" 134 | config["paths"]["ext:file"] = "$albumpath/$title" 135 | config["replace"][r"\?"] = "\uff1f" 136 | 137 | self.lib.replacements = [ 138 | (re.compile(r"\:"), "_"), 139 | (re.compile(r"\?"), "\uff1f"), 140 | ] 141 | 142 | self.create_file(self.album_path, beets.util.bytestring_path("artifact.file")) 143 | medium = self._create_medium( 144 | os.path.join(self.album_path, b"track_1.mp3"), 145 | self.rsrc_mp3, 146 | MediaMeta(title="Tag: Title?"), 147 | ) 148 | self.import_media = [medium] 149 | 150 | self._run_cli_command("import") 151 | 152 | self.assert_in_lib_dir( 153 | b"Tag Artist", 154 | b"Tag Album", 155 | beets.util.bytestring_path("Tag_ Title\uff1f.file"), 156 | ) 157 | -------------------------------------------------------------------------------- /tests/test_filesize_fixes.py: -------------------------------------------------------------------------------- 1 | """Tests to ensure no "could not get filesize" error occurs in the beets-filetote 2 | plugin. 3 | """ 4 | 5 | from typing import List, Optional 6 | 7 | from beets import config 8 | 9 | from tests.helper import FiletoteTestCase, capture_log 10 | 11 | 12 | class FiletoteNoFilesizeErrorTest(FiletoteTestCase): 13 | """Tests to ensure no "could not get filesize" error occurs.""" 14 | 15 | def setUp(self, _other_plugins: Optional[List[str]] = None) -> None: 16 | """Provides shared setup for tests.""" 17 | super().setUp() 18 | 19 | self._create_flat_import_dir() 20 | self._setup_import_session(autotag=False) 21 | 22 | def test_no_filesize_error(self) -> None: 23 | """Tests to ensure no "could not get filesize" error occurs by confirming no 24 | warning log is emitted and ensuring the hidden filesize metadata value is 25 | not `0`. 26 | """ 27 | config["filetote"]["extensions"] = ".file .lrc" 28 | config["paths"]["ext:file"] = "$albumpath/filesize - ${filesize}b" 29 | 30 | with capture_log() as logs: 31 | self._run_cli_command("import", operation_option="move") 32 | 33 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.nfo") 34 | 35 | # check output log 36 | matching_logs = [ 37 | line for line in logs if line.startswith("could not get filesize:") 38 | ] 39 | assert not matching_logs 40 | 41 | self.assert_in_lib_dir( 42 | b"Tag Artist", 43 | b"Tag Album", 44 | b"filesize - 12820b.file", 45 | ) 46 | -------------------------------------------------------------------------------- /tests/test_flatdirectory.py: -------------------------------------------------------------------------------- 1 | """Tests flat directory structure for the beets-filetote plugin.""" 2 | 3 | import logging 4 | import os 5 | 6 | from typing import List, Optional 7 | 8 | from beets import config 9 | 10 | from tests.helper import FiletoteTestCase 11 | 12 | log = logging.getLogger("beets") 13 | 14 | 15 | class FiletoteFromFlatDirectoryTest(FiletoteTestCase): 16 | """Tests to check that Filetote copies or moves artifact files from a 17 | flat directory (e.g., all songs in an album are imported from a single 18 | directory). Also tests `extensions` and `filenames` config options. 19 | """ 20 | 21 | def setUp(self, _other_plugins: Optional[List[str]] = None) -> None: 22 | """Provides shared setup for tests.""" 23 | super().setUp() 24 | 25 | self._create_flat_import_dir() 26 | self._setup_import_session(autotag=False) 27 | 28 | self._base_file_count = self._media_count + self._pairs_count 29 | 30 | def test_only_copies_files_matching_configured_extension(self) -> None: 31 | """Test that Filetote only copies files by specific extension when set.""" 32 | config["filetote"]["extensions"] = ".file" 33 | 34 | self._run_cli_command("import") 35 | 36 | self.assert_number_of_files_in_dir( 37 | self._media_count + 2, self.lib_dir, b"Tag Artist", b"Tag Album" 38 | ) 39 | 40 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 41 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact2.file") 42 | 43 | self.assert_in_import_dir(b"the_album", b"artifact.nfo") 44 | self.assert_in_import_dir(b"the_album", b"artifact.lrc") 45 | 46 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.nfo") 47 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.lrc") 48 | 49 | def test_exact_matching_configured_extension(self) -> None: 50 | """Test that extensions and other fields matching are exact, not just 51 | partial matches. 52 | """ 53 | config["filetote"]["extensions"] = ".file" 54 | 55 | self.create_file(os.path.join(self.import_dir, b"the_album"), b"artifact.file2") 56 | 57 | self._run_cli_command("import") 58 | 59 | self.assert_number_of_files_in_dir( 60 | self._media_count + 2, self.lib_dir, b"Tag Artist", b"Tag Album" 61 | ) 62 | 63 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 64 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact2.file") 65 | 66 | self.assert_in_import_dir(b"the_album", b"artifact.file2") 67 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file2") 68 | 69 | def test_exclude_artifacts_matching_configured_exclude(self) -> None: 70 | """Test that specified excludes are not moved/copied.""" 71 | config["filetote"]["extensions"] = ".file" 72 | config["filetote"]["exclude"] = "artifact2.file" 73 | 74 | self._run_cli_command("import") 75 | 76 | self.assert_number_of_files_in_dir( 77 | self._media_count + 1, self.lib_dir, b"Tag Artist", b"Tag Album" 78 | ) 79 | 80 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 81 | 82 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact2.file") 83 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.nfo") 84 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.lrc") 85 | 86 | def test_only_copy_artifacts_matching_configured_filename(self) -> None: 87 | """Test that only the specific file (by filename) is copied when specified.""" 88 | config["filetote"]["filenames"] = "artifact.file" 89 | 90 | self._run_cli_command("import") 91 | 92 | self.assert_number_of_files_in_dir( 93 | self._media_count + 1, self.lib_dir, b"Tag Artist", b"Tag Album" 94 | ) 95 | 96 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 97 | 98 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact2.file") 99 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.nfo") 100 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.lrc") 101 | 102 | def test_only_copy_artifacts_matching_configured_extension_and_filename( 103 | self, 104 | ) -> None: 105 | """Test that multiple definitions work and the all matches copy.""" 106 | config["filetote"]["extensions"] = ".file" 107 | config["filetote"]["filenames"] = "artifact.nfo" 108 | 109 | self._run_cli_command("import") 110 | 111 | self.assert_number_of_files_in_dir( 112 | self._media_count + 3, self.lib_dir, b"Tag Artist", b"Tag Album" 113 | ) 114 | 115 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 116 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact2.file") 117 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.nfo") 118 | 119 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.lrc") 120 | 121 | def test_copy_no_artifacts_by_default(self) -> None: 122 | """Ensure that all artifacts that match the extensions are moved by default.""" 123 | self._run_cli_command("import") 124 | 125 | self.assert_number_of_files_in_dir( 126 | self._media_count, self.lib_dir, b"Tag Artist", b"Tag Album" 127 | ) 128 | 129 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 130 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact2.file") 131 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.nfo") 132 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.lrc") 133 | -------------------------------------------------------------------------------- /tests/test_manipulate_files.py: -------------------------------------------------------------------------------- 1 | """Tests manipulation of files for the beets-filetote plugin.""" 2 | 3 | import logging 4 | import os 5 | import stat 6 | 7 | from typing import List, Optional 8 | 9 | import pytest 10 | 11 | from beets import config, util 12 | 13 | from tests import _common 14 | from tests.helper import FiletoteTestCase 15 | 16 | log = logging.getLogger("beets") 17 | 18 | 19 | class FiletoteManipulateFiles(FiletoteTestCase): 20 | """Tests to check that Filetote manipulates files using the correct operation.""" 21 | 22 | def setUp(self, _other_plugins: Optional[List[str]] = None) -> None: 23 | """Provides shared setup for tests.""" 24 | super().setUp() 25 | 26 | self._create_flat_import_dir() 27 | self._setup_import_session(autotag=False, copy=False) 28 | 29 | self._base_file_count = self._media_count + self._pairs_count 30 | 31 | def test_copy_artifacts(self) -> None: 32 | """Test that copy actually copies (and not just moves).""" 33 | config["import"]["copy"] = True 34 | config["filetote"]["extensions"] = ".*" 35 | 36 | self._run_cli_command("import") 37 | 38 | self.assert_number_of_files_in_dir( 39 | self._base_file_count + 4, self.lib_dir, b"Tag Artist", b"Tag Album" 40 | ) 41 | 42 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 43 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact2.file") 44 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.nfo") 45 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.lrc") 46 | 47 | def test_move_artifacts(self) -> None: 48 | """Test that move actually moves (and not just copies).""" 49 | config["import"]["move"] = True 50 | config["filetote"]["extensions"] = ".*" 51 | 52 | self._run_cli_command("import") 53 | 54 | self.assert_number_of_files_in_dir( 55 | self._base_file_count + 4, self.lib_dir, b"Tag Artist", b"Tag Album" 56 | ) 57 | 58 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 59 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact2.file") 60 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.nfo") 61 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.lrc") 62 | 63 | self.assert_not_in_import_dir(b"the_album", b"artifact.file") 64 | self.assert_not_in_import_dir(b"the_album", b"artifact2.file") 65 | self.assert_not_in_import_dir(b"the_album", b"artifact.nfo") 66 | self.assert_not_in_import_dir(b"the_album", b"artifact.lrc") 67 | 68 | def test_artifacts_copymove_on_first_media_by_default(self) -> None: 69 | """By default, all eligible files are grabbed with the first item.""" 70 | config["filetote"]["extensions"] = ".file" 71 | config["paths"]["ext:file"] = "$albumpath/$medianame_old - $old_filename" 72 | 73 | config["import"]["copy"] = True 74 | 75 | self._run_cli_command("import") 76 | 77 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1 - artifact.file") 78 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1 - artifact2.file") 79 | 80 | @pytest.mark.skipif(not _common.HAVE_SYMLINK, reason="need symlinks") 81 | def test_import_symlink_files(self) -> None: 82 | """Tests that the `symlink` operation correctly symlinks files.""" 83 | config["filetote"]["extensions"] = ".file" 84 | config["paths"]["ext:file"] = "$albumpath/newname" 85 | config["import"]["link"] = True 86 | 87 | old_path = os.path.join( 88 | self.import_dir, 89 | b"the_album", 90 | b"artifact.file", 91 | ) 92 | 93 | new_path = os.path.join( 94 | self.lib_dir, 95 | b"Tag Artist", 96 | b"Tag Album", 97 | b"newname.file", 98 | ) 99 | 100 | self._run_cli_command("import") 101 | 102 | self.assert_in_import_dir(b"the_album", b"artifact.file") 103 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"newname.file") 104 | 105 | self.assert_islink(b"Tag Artist", b"Tag Album", b"newname.file") 106 | 107 | self.assert_equal_path(util.bytestring_path(os.readlink(new_path)), old_path) 108 | 109 | @pytest.mark.skipif(not _common.HAVE_HARDLINK, reason="need hardlinks") 110 | def test_import_hardlink_files(self) -> None: 111 | """Tests that the `hardlink` operation correctly hardlinks files.""" 112 | config["filetote"]["extensions"] = ".file" 113 | config["paths"]["ext:file"] = "$albumpath/newname" 114 | config["import"]["hardlink"] = True 115 | 116 | old_path = os.path.join( 117 | self.import_dir, 118 | b"the_album", 119 | b"artifact.file", 120 | ) 121 | 122 | new_path = os.path.join( 123 | self.lib_dir, 124 | b"Tag Artist", 125 | b"Tag Album", 126 | b"newname.file", 127 | ) 128 | 129 | self._run_cli_command("import") 130 | 131 | self.assert_in_import_dir(b"the_album", b"artifact.file") 132 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"newname.file") 133 | 134 | stat_old_path = os.stat(old_path) 135 | stat_new_path = os.stat(new_path) 136 | 137 | assert (stat_old_path[stat.ST_INO], stat_old_path[stat.ST_DEV]) == ( 138 | stat_new_path[stat.ST_INO], 139 | stat_new_path[stat.ST_DEV], 140 | ) 141 | 142 | @pytest.mark.skipif(not _common.HAVE_REFLINK, reason="need reflinks") 143 | def test_import_reflink_files(self) -> None: 144 | """Tests that the `reflink` operation correctly links files.""" 145 | config["filetote"]["extensions"] = ".file" 146 | config["paths"]["ext:file"] = "$albumpath/newname" 147 | config["import"]["reflink"] = True 148 | 149 | self._run_cli_command("import") 150 | 151 | self.assert_in_import_dir(b"the_album", b"artifact.file") 152 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"newname.file") 153 | -------------------------------------------------------------------------------- /tests/test_music_files.py: -------------------------------------------------------------------------------- 1 | """Tests that music files are ignored for the beets-filetote plugin.""" 2 | 3 | import logging 4 | 5 | from beets import config 6 | from mediafile import TYPES as BEETS_TYPES 7 | 8 | from tests.helper import FiletoteTestCase, MediaSetup 9 | 10 | log = logging.getLogger("beets") 11 | 12 | 13 | class FiletoteMusicFilesIgnoredTest(FiletoteTestCase): 14 | """Tests to check that Filetote only copies or moves artifact files and not 15 | music files as defined by MediaFile's TYPES and expanded list. 16 | """ 17 | 18 | def test_default_music_file_types_are_ignored(self) -> None: 19 | """Ensure that mediafile types are ignored by Filetote.""" 20 | media_file_list = [ 21 | MediaSetup(file_type=beet_type, count=1) for beet_type in BEETS_TYPES 22 | ] 23 | 24 | self._create_flat_import_dir(media_files=media_file_list) 25 | self._setup_import_session(autotag=False) 26 | 27 | config["filetote"]["extensions"] = ".*" 28 | 29 | self._run_cli_command("import") 30 | 31 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.aac") 32 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.aiff") 33 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.alac") 34 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.ape") 35 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.asf") 36 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.dsf") 37 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.flac") 38 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.mp3") 39 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.mpc") 40 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.ogg") 41 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.opus") 42 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.wav") 43 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.wv") 44 | 45 | def test_expanded_music_file_types_are_ignored(self) -> None: 46 | """Ensure that `.m4a`, `.alac.m4a`, `.wma`, and `.wave` file types are 47 | ignored by Filetote. 48 | """ 49 | media_file_list = [ 50 | MediaSetup(file_type="m4a", count=1), 51 | MediaSetup(file_type="alac.m4a", count=1), 52 | MediaSetup(file_type="wma", count=1), 53 | MediaSetup(file_type="wave", count=1), 54 | ] 55 | 56 | self._create_flat_import_dir(media_files=media_file_list) 57 | self._setup_import_session(autotag=False) 58 | 59 | config["filetote"]["extensions"] = ".*" 60 | 61 | self._run_cli_command("import") 62 | 63 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.m4a") 64 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.alac.m4a") 65 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.wma") 66 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.wave") 67 | -------------------------------------------------------------------------------- /tests/test_nesteddirectory.py: -------------------------------------------------------------------------------- 1 | """Tests nested directories for the beets-filetote plugin.""" 2 | 3 | import logging 4 | import os 5 | 6 | from typing import List, Optional 7 | 8 | import pytest 9 | 10 | from beets import config 11 | 12 | from tests import _common 13 | from tests.helper import FiletoteTestCase 14 | 15 | log = logging.getLogger("beets") 16 | 17 | 18 | class FiletoteFromNestedDirectoryTest(FiletoteTestCase): 19 | """Tests to check that Filetote copies or moves artifact files from a nested 20 | directory structure. i.e. songs in an album are imported from two directories 21 | corresponding to disc numbers or flat option is used. 22 | """ 23 | 24 | def setUp(self, _other_plugins: Optional[List[str]] = None) -> None: 25 | """Provides shared setup for tests.""" 26 | super().setUp() 27 | 28 | self._create_nested_import_dir() 29 | self._setup_import_session(autotag=False) 30 | 31 | self._base_file_count = self._media_count + self._pairs_count 32 | 33 | def test_copies_file_from_nested_to_library(self) -> None: 34 | """Ensures that nested directories are handled by beets and the the files 35 | relocate as expected following the default beets behavior (moves to a 36 | single directory). 37 | """ 38 | config["filetote"]["extensions"] = ".file" 39 | 40 | self._run_cli_command("import") 41 | 42 | self.assert_number_of_files_in_dir( 43 | self._media_count + 4, self.lib_dir, b"Tag Artist", b"Tag Album" 44 | ) 45 | 46 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 47 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact2.file") 48 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact3.file") 49 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact4.file") 50 | 51 | self.assert_in_import_dir(b"the_album", b"disc1", b"artifact_disc1.nfo") 52 | self.assert_in_import_dir(b"the_album", b"disc2", b"artifact_disc2.nfo") 53 | 54 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact_disc1.nfo") 55 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact_disc2.lrc") 56 | 57 | def test_copies_file_from_nested_to_nested_library(self) -> None: 58 | """Ensures that nested directory artifacts are relocated as expected 59 | when beets is set to use a nested library destination. 60 | """ 61 | config["filetote"]["extensions"] = ".file" 62 | self.lib.path_formats = [ 63 | ("default", os.path.join("$artist", "$album", "$disc", "$title")), 64 | ] 65 | 66 | self._run_cli_command("import") 67 | 68 | self.assert_number_of_files_in_dir( 69 | 5, self.lib_dir, b"Tag Artist", b"Tag Album", b"01" 70 | ) 71 | self.assert_number_of_files_in_dir( 72 | 5, self.lib_dir, b"Tag Artist", b"Tag Album", b"02" 73 | ) 74 | 75 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"01", b"artifact.file") 76 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"01", b"artifact2.file") 77 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"02", b"artifact3.file") 78 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"02", b"artifact4.file") 79 | 80 | self.assert_in_import_dir(b"the_album", b"disc1", b"artifact_disc1.nfo") 81 | self.assert_in_import_dir(b"the_album", b"disc2", b"artifact_disc2.nfo") 82 | 83 | self.assert_not_in_lib_dir( 84 | b"Tag Artist", b"Tag Album", b"01", b"artifact_disc1.nfo" 85 | ) 86 | self.assert_not_in_lib_dir( 87 | b"Tag Artist", b"Tag Album", b"02", b"artifact_disc2.lrc" 88 | ) 89 | 90 | @pytest.mark.skipif(_common.PLATFORM == "win32", reason="win32") 91 | def test_copies_file_navigate_in_nested_library(self) -> None: 92 | """Ensures that nested directory artifacts are relocated using `..` without 93 | issue. This is skipped in Windows since `..` is taken literally instead of as 94 | a path component. 95 | """ 96 | config["filetote"]["extensions"] = ".file" 97 | config["filetote"]["paths"] = { 98 | "ext:file": os.path.join("$albumpath", "..", "artifacts", "$old_filename"), 99 | } 100 | 101 | self.lib.path_formats = [ 102 | ("default", os.path.join("$artist", "$album", "$disc", "$title")), 103 | ] 104 | 105 | self._run_cli_command("import") 106 | 107 | self.assert_number_of_files_in_dir( 108 | 3, self.lib_dir, b"Tag Artist", b"Tag Album", b"01" 109 | ) 110 | self.assert_number_of_files_in_dir( 111 | 3, self.lib_dir, b"Tag Artist", b"Tag Album", b"02" 112 | ) 113 | 114 | self.assert_in_lib_dir( 115 | b"Tag Artist", b"Tag Album", b"artifacts", b"artifact.file" 116 | ) 117 | self.assert_in_lib_dir( 118 | b"Tag Artist", b"Tag Album", b"artifacts", b"artifact2.file" 119 | ) 120 | self.assert_in_lib_dir( 121 | b"Tag Artist", b"Tag Album", b"artifacts", b"artifact3.file" 122 | ) 123 | self.assert_in_lib_dir( 124 | b"Tag Artist", b"Tag Album", b"artifacts", b"artifact4.file" 125 | ) 126 | -------------------------------------------------------------------------------- /tests/test_pairing.py: -------------------------------------------------------------------------------- 1 | """Tests pairing the beets-filetote plugin.""" 2 | 3 | import logging 4 | import os 5 | 6 | from beets import config 7 | 8 | from tests.helper import FiletoteTestCase, MediaSetup 9 | 10 | log = logging.getLogger("beets") 11 | 12 | 13 | class FiletotePairingTest(FiletoteTestCase): 14 | """Tests to check that Filetote handles "pairs" of files.""" 15 | 16 | def test_pairing_default_is_disabled(self) -> None: 17 | """Ensure that, by default, pairing is disabled.""" 18 | self._create_flat_import_dir(media_files=[MediaSetup(count=1)]) 19 | self._setup_import_session(autotag=False) 20 | 21 | config["filetote"]["extensions"] = ".lrc" 22 | 23 | self._run_cli_command("import") 24 | 25 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.lrc") 26 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.lrc") 27 | 28 | def test_pairingonly_requires_pairing_enabled(self) -> None: 29 | """Test that without `enabled`, `pairing_only` does nothing.""" 30 | self._create_flat_import_dir() 31 | self._setup_import_session(autotag=False) 32 | 33 | config["filetote"]["extensions"] = ".lrc" 34 | config["filetote"]["pairing"] = { 35 | "enabled": False, 36 | "pairing_only": True, 37 | } 38 | 39 | self._run_cli_command("import") 40 | 41 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.lrc") 42 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.lrc") 43 | 44 | def test_pairing_disabled_copies_all_matches(self) -> None: 45 | """Ensure that when pairing is disabled it does not do anything with pairs.""" 46 | self._create_flat_import_dir(media_files=[MediaSetup(count=1)]) 47 | self._setup_import_session(autotag=False) 48 | 49 | config["filetote"]["extensions"] = ".lrc" 50 | config["filetote"]["pairing"]["enabled"] = False 51 | 52 | self._run_cli_command("import") 53 | 54 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.lrc") 55 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.lrc") 56 | 57 | def test_pairing_enabled_copies_all_matches(self) -> None: 58 | """ENsure that all pairs are copied.""" 59 | self._create_flat_import_dir(media_files=[MediaSetup(count=2)]) 60 | self._setup_import_session(autotag=False) 61 | 62 | config["filetote"]["extensions"] = ".lrc" 63 | config["filetote"]["pairing"]["enabled"] = True 64 | 65 | self._run_cli_command("import") 66 | 67 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.lrc") 68 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_2.lrc") 69 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.lrc") 70 | 71 | def test_pairing_enabled_works_without_pairs(self) -> None: 72 | """Ensure that even when there's not a pair, other files can be handled.""" 73 | self._create_flat_import_dir( 74 | media_files=[MediaSetup(count=1, generate_pair=False)] 75 | ) 76 | self._setup_import_session(autotag=False) 77 | 78 | config["filetote"]["extensions"] = ".lrc" 79 | config["filetote"]["pairing"]["enabled"] = True 80 | 81 | self._run_cli_command("import") 82 | 83 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.lrc") 84 | 85 | def test_pairing_does_not_require_pairs_for_all_media(self) -> None: 86 | """Ensure that when there's not a pair, other paired files still copy.""" 87 | self._create_flat_import_dir( 88 | media_files=[MediaSetup(count=2, generate_pair=False)] 89 | ) 90 | self._setup_import_session(autotag=False) 91 | 92 | config["filetote"]["extensions"] = ".lrc" 93 | config["filetote"]["pairing"]["enabled"] = True 94 | 95 | self.create_file(os.path.join(self.import_dir, b"the_album"), b"track_1.lrc") 96 | 97 | self._run_cli_command("import") 98 | 99 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.lrc") 100 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.lrc") 101 | 102 | def test_pairingonly_disabled_copies_all_matches(self) -> None: 103 | """Ensure that `pairing_only` disabled allows other matches to an 104 | extension to be handled. 105 | """ 106 | self._create_flat_import_dir(media_files=[MediaSetup(count=2)]) 107 | self._setup_import_session(autotag=False) 108 | 109 | config["filetote"]["extensions"] = ".lrc" 110 | config["filetote"]["pairing"] = { 111 | "enabled": True, 112 | "pairing_only": False, 113 | } 114 | 115 | self._run_cli_command("import") 116 | 117 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.lrc") 118 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_2.lrc") 119 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.lrc") 120 | 121 | def test_pairingonly_enabled_copies_all_matches(self) -> None: 122 | """Test that `pairing_only` means that only pairs meeting a certain 123 | extension are handled. 124 | """ 125 | self._create_flat_import_dir(media_files=[MediaSetup(count=2)]) 126 | self._setup_import_session(autotag=False) 127 | 128 | config["filetote"]["extensions"] = ".lrc" 129 | config["filetote"]["pairing"] = { 130 | "enabled": True, 131 | "pairing_only": True, 132 | } 133 | 134 | self._run_cli_command("import") 135 | 136 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.lrc") 137 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_2.lrc") 138 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.lrc") 139 | 140 | def test_pairingonly_does_not_require_pairs_for_all_media(self) -> None: 141 | """Ensure that `pairing_only` does not require all media files for pairs to 142 | move/copy. 143 | """ 144 | self._create_flat_import_dir( 145 | media_files=[MediaSetup(count=2, generate_pair=False)] 146 | ) 147 | self._setup_import_session(autotag=False) 148 | 149 | config["filetote"]["extensions"] = ".lrc" 150 | config["filetote"]["pairing"] = { 151 | "enabled": True, 152 | "pairing_only": True, 153 | } 154 | 155 | self.create_file(os.path.join(self.import_dir, b"the_album"), b"track_1.lrc") 156 | 157 | self._run_cli_command("import") 158 | 159 | self.assert_in_import_dir(b"the_album", b"track_1.lrc") 160 | self.assert_in_import_dir(b"the_album", b"artifact.lrc") 161 | 162 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.lrc") 163 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.lrc") 164 | 165 | def test_pairing_extensions(self) -> None: 166 | """Ensure that paired extensions are seen and manipulated.""" 167 | self._create_flat_import_dir( 168 | media_files=[MediaSetup(count=2, generate_pair=False)] 169 | ) 170 | self._setup_import_session(autotag=False) 171 | 172 | config["filetote"]["pairing"] = { 173 | "enabled": True, 174 | "pairing_only": True, 175 | "extensions": ".lrc .kar", 176 | } 177 | 178 | new_files = [b"track_1.kar", b"track_1.lrc", b"track_1.jpg"] 179 | 180 | for filename in new_files: 181 | self.create_file(os.path.join(self.import_dir, b"the_album"), filename) 182 | 183 | self._run_cli_command("import") 184 | 185 | self.assert_in_import_dir(b"the_album", b"track_1.lrc") 186 | self.assert_in_import_dir(b"the_album", b"artifact.lrc") 187 | 188 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.lrc") 189 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.kar") 190 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.jpg") 191 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.lrc") 192 | 193 | def test_pairing_extensions_are_addative_to_toplevel_extensions(self) -> None: 194 | """Ensure that those extensions defined in pairing extend any extensions 195 | defined in the `extensions` config. 196 | """ 197 | self._create_flat_import_dir( 198 | media_files=[MediaSetup(count=2, generate_pair=False)] 199 | ) 200 | self._setup_import_session(autotag=False) 201 | 202 | config["filetote"]["extensions"] = ".jpg" 203 | 204 | config["filetote"]["pairing"] = { 205 | "enabled": True, 206 | "extensions": ".lrc", 207 | } 208 | 209 | new_files = [b"track_1.kar", b"track_1.lrc", b"track_1.jpg"] 210 | 211 | for filename in new_files: 212 | self.create_file(os.path.join(self.import_dir, b"the_album"), filename) 213 | 214 | self._run_cli_command("import") 215 | 216 | self.assert_in_import_dir(b"the_album", b"track_1.lrc") 217 | self.assert_in_import_dir(b"the_album", b"artifact.lrc") 218 | 219 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.lrc") 220 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.jpg") 221 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.kar") 222 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.lrc") 223 | -------------------------------------------------------------------------------- /tests/test_patterns.py: -------------------------------------------------------------------------------- 1 | """Tests "pattern" functionality for the beets-filetote plugin.""" 2 | 3 | import logging 4 | import os 5 | 6 | from typing import List, Optional 7 | 8 | from beets import config 9 | 10 | from tests.helper import FiletoteTestCase, capture_log 11 | 12 | log = logging.getLogger("beets") 13 | 14 | 15 | class FiletotePatternTest(FiletoteTestCase): 16 | """Tests to check that Filetote grabs artfacts by user-definited patterns.""" 17 | 18 | def setUp(self, _other_plugins: Optional[List[str]] = None) -> None: 19 | """Provides shared setup for tests.""" 20 | super().setUp() 21 | 22 | self._create_flat_import_dir() 23 | self._setup_import_session(autotag=False) 24 | 25 | def test_patterns_match_all_glob(self) -> None: 26 | """Tests that the `*.*` pattern matches all artifacts.""" 27 | config["filetote"]["patterns"] = { 28 | "all-pattern": ["*.*"], 29 | } 30 | 31 | self._run_cli_command("import") 32 | 33 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.lrc") 34 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_2.lrc") 35 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_3.lrc") 36 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.lrc") 37 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 38 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact2.file") 39 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.nfo") 40 | 41 | def test_patterns_match(self) -> None: 42 | """Tests that patterns are used to itentify artifacts.""" 43 | config["filetote"]["patterns"] = { 44 | "file-pattern": ["[aA]rtifact.file", "artifact[23].file"], 45 | "nfo-pattern": ["*.nfo"], 46 | } 47 | 48 | self._run_cli_command("import") 49 | 50 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 51 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact2.file") 52 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.nfo") 53 | 54 | def test_patterns_subfolders_match(self) -> None: 55 | """Tests that patterns can match subdirectories/subfolders.""" 56 | artwork_dir = os.path.join(self.import_dir, b"the_album", b"artwork") 57 | os.makedirs(artwork_dir) 58 | 59 | self.create_file( 60 | path=artwork_dir, 61 | filename=b"cover.jpg", 62 | ) 63 | 64 | config["filetote"]["patterns"] = { 65 | "file-pattern": ["/[aA]rtifact.file", "artifact[23].file"], 66 | "subfolder-pattern": ["/[aA]rtwork/cover.jpg"], 67 | } 68 | 69 | config["paths"]["pattern:subfolder-pattern"] = os.path.join( 70 | "$albumpath", "artwork", "$old_filename" 71 | ) 72 | 73 | self._run_cli_command("import") 74 | 75 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 76 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact2.file") 77 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artwork", b"cover.jpg") 78 | 79 | def test_patterns_of_folders_grab_all_files(self) -> None: 80 | """Tests that patterns of just folders grab all contents.""" 81 | artwork_dir = os.path.join(self.import_dir, b"the_album", b"artwork") 82 | cd1_dir = os.path.join(self.import_dir, b"the_album", b"CD1") 83 | subfolder_dir = os.path.join( 84 | self.import_dir, b"the_album", b"Subfolder1", b"Subfolder2", b"Subfolder3" 85 | ) 86 | os.makedirs(artwork_dir) 87 | os.makedirs(cd1_dir) 88 | os.makedirs(subfolder_dir) 89 | 90 | self.create_file( 91 | path=artwork_dir, 92 | filename=b"cover.jpg", 93 | ) 94 | self.create_file( 95 | path=cd1_dir, 96 | filename=b"cd.file", 97 | ) 98 | self.create_file( 99 | path=subfolder_dir, 100 | filename=b"sub.file", 101 | ) 102 | 103 | config["filetote"]["patterns"] = { 104 | "subfolder1-pattern": ["[aA]rtwork/"], 105 | "subfolder2-pattern": ["CD1/*.*"], 106 | "subfolder3-pattern": ["Subfolder1/Subfolder2/"], 107 | } 108 | 109 | config["paths"]["pattern:subfolder1-pattern"] = os.path.join( 110 | "$albumpath", "artwork", "$old_filename" 111 | ) 112 | 113 | config["paths"]["pattern:subfolder3-pattern"] = os.path.join( 114 | "$albumpath", "sub1", "sub2", "$old_filename" 115 | ) 116 | 117 | self._run_cli_command("import") 118 | 119 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artwork", b"cover.jpg") 120 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"cd.file") 121 | self.assert_in_lib_dir( 122 | b"Tag Artist", b"Tag Album", b"sub1", b"sub2", b"sub.file" 123 | ) 124 | 125 | def test_patterns_path_renaming(self) -> None: 126 | """Tests that the path definition for `pattern:` prefix works.""" 127 | config["filetote"]["patterns"] = { 128 | "file-pattern": ["[Aa]rtifact.file", "artifact[23].file"], 129 | "nfo-pattern": ["*.nfo"], 130 | } 131 | config["paths"]["pattern:file-pattern"] = ( 132 | "$albumpath/file-pattern $old_filename" 133 | ) 134 | 135 | config["paths"]["pattern:nfo-pattern"] = "$albumpath/nfo-pattern $old_filename" 136 | 137 | with capture_log() as logs: 138 | self._run_cli_command("import") 139 | 140 | for line in logs: 141 | if line.startswith("filetote:"): 142 | log.info(line) 143 | 144 | self.assert_in_lib_dir( 145 | b"Tag Artist", b"Tag Album", b"file-pattern artifact.file" 146 | ) 147 | self.assert_in_lib_dir( 148 | b"Tag Artist", b"Tag Album", b"file-pattern artifact2.file" 149 | ) 150 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"nfo-pattern artifact.nfo") 151 | -------------------------------------------------------------------------------- /tests/test_printignored.py: -------------------------------------------------------------------------------- 1 | """Tests print ignored for the beets-filetote plugin.""" 2 | 3 | from typing import List, Optional 4 | 5 | from beets import config 6 | 7 | from tests.helper import FiletoteTestCase, capture_log 8 | 9 | 10 | class FiletotePrintIgnoredTest(FiletoteTestCase): 11 | """Tests to check print ignored files functionality and configuration.""" 12 | 13 | def setUp(self, _other_plugins: Optional[List[str]] = None) -> None: 14 | """Provides shared setup for tests.""" 15 | super().setUp() 16 | 17 | self._create_flat_import_dir() 18 | self._setup_import_session(autotag=False) 19 | 20 | def test_do_not_print_ignored_by_default(self) -> None: 21 | """Tests to ensure the default behavior for printing ignored is "disabled".""" 22 | config["filetote"]["extensions"] = ".file" 23 | 24 | with capture_log() as logs: 25 | self._run_cli_command("import") 26 | 27 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.nfo") 28 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.lrc") 29 | 30 | # check output log 31 | logs = [line for line in logs if line.startswith("filetote:")] 32 | assert logs == [] 33 | 34 | def test_print_ignored(self) -> None: 35 | """Tests that when `print_ignored` is enabled, it prints out all files not 36 | handled by Filetote. 37 | """ 38 | config["filetote"]["print_ignored"] = True 39 | config["filetote"]["extensions"] = ".file .lrc" 40 | 41 | with capture_log() as logs: 42 | self._run_cli_command("import") 43 | 44 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.nfo") 45 | 46 | # check output log 47 | logs = [line for line in logs if line.startswith("filetote:")] 48 | assert logs == [ 49 | "filetote: Ignored files:", 50 | "filetote: artifact.nfo", 51 | ] 52 | -------------------------------------------------------------------------------- /tests/test_pruning.py: -------------------------------------------------------------------------------- 1 | """Tests pruning for the beets-filetote plugin.""" 2 | 3 | import logging 4 | import os 5 | 6 | from typing import List, Optional 7 | 8 | from beets import config 9 | 10 | from tests.helper import FiletoteTestCase 11 | 12 | log = logging.getLogger("beets") 13 | 14 | 15 | class FiletotePruningyTest(FiletoteTestCase): 16 | """Tests to check that Filetote correctly "prunes" directories when 17 | it moves artifact files. 18 | """ 19 | 20 | def setUp(self, _other_plugins: Optional[List[str]] = None) -> None: 21 | """Provides shared setup for tests.""" 22 | super().setUp() 23 | 24 | self._create_flat_import_dir() 25 | self._setup_import_session(autotag=False, move=True) 26 | 27 | def test_prune_import_directory_when_emptied(self) -> None: 28 | """Check that plugin does not interfere with normal 29 | pruning of emptied import directories. 30 | """ 31 | config["filetote"]["extensions"] = ".*" 32 | 33 | self._run_cli_command("import") 34 | 35 | self.assert_import_dir_exists() 36 | self.assert_not_in_import_dir(b"the_album") 37 | 38 | def test_prune_import_subdirectory_only_not_above(self) -> None: 39 | """Check that plugin only prunes nested folder when specified.""" 40 | self._setup_import_session( 41 | autotag=False, 42 | import_dir=os.path.join(self.import_dir, b"the_album"), 43 | move=True, 44 | ) 45 | config["filetote"]["extensions"] = ".*" 46 | self._run_cli_command("import") 47 | 48 | self.assert_import_dir_exists(self.import_dir) 49 | self.assert_not_in_import_dir(b"the_album") 50 | 51 | def test_prune_import_expands_user_import_path(self) -> None: 52 | """Check that plugin prunes and converts/expands the user parts of path if 53 | present. 54 | """ 55 | self._setup_import_session( 56 | autotag=False, 57 | import_dir=os.path.join(self.import_dir, b"the_album"), 58 | move=True, 59 | ) 60 | config["filetote"]["extensions"] = ".*" 61 | self._run_cli_command("import") 62 | 63 | self.assert_import_dir_exists(self.import_dir) 64 | self.assert_not_in_import_dir(b"the_album") 65 | 66 | def test_prune_reimport_move(self) -> None: 67 | """Check that plugin prunes to the root of the library when reimporting 68 | from library. 69 | 70 | Setup subsequent import directory of the following structure: 71 | 72 | testlib_dir/ 73 | Tag Artist/ 74 | Tag Album/ 75 | Tag Title 1.mp3 76 | Tag Title 2.mp3 77 | Tag Title 3.mp3 78 | artifact.file 79 | artifact2.file 80 | """ 81 | config["filetote"]["extensions"] = ".file" 82 | 83 | log.debug("--- initial import") 84 | self._run_cli_command("import") 85 | 86 | self.lib.path_formats[0] = ( 87 | "default", 88 | os.path.join("1$artist", "$album", "$title"), 89 | ) 90 | self._setup_import_session(autotag=False, import_dir=self.lib_dir, move=True) 91 | 92 | log.debug("--- second import") 93 | 94 | self._run_cli_command("import") 95 | 96 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album") 97 | self.assert_not_in_lib_dir(b"Tag Artist") 98 | self.assert_in_lib_dir(b"1Tag Artist", b"Tag Album", b"artifact.file") 99 | 100 | def test_prune_reimport_copy(self) -> None: 101 | """Ensure directories are pruned when reimporting with 'copy'. The 102 | operation gets changed to `move` when the media file is already in the 103 | library (hence, reimport). 104 | 105 | Setup subsequent import directory of the following structure: 106 | 107 | testlib_dir/ 108 | Tag Artist/ 109 | Tag Album/ 110 | Tag Title 1.mp3 111 | Tag Title 2.mp3 112 | Tag Title 3.mp3 113 | artifact.file 114 | artifact2.file 115 | """ 116 | config["filetote"]["extensions"] = ".file" 117 | 118 | log.debug("--- initial import") 119 | self._run_cli_command("import") 120 | 121 | self.lib.path_formats[0] = ( 122 | "default", 123 | os.path.join("1$artist", "$album", "$title"), 124 | ) 125 | self._setup_import_session(autotag=False, import_dir=self.lib_dir, copy=True) 126 | 127 | log.debug("--- second import") 128 | 129 | self._run_cli_command("import") 130 | 131 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album") 132 | self.assert_not_in_lib_dir(b"Tag Artist") 133 | self.assert_in_lib_dir(b"1Tag Artist", b"Tag Album", b"artifact.file") 134 | 135 | def test_prune_reimport_query(self) -> None: 136 | """Check that plugin prunes to the root of the library when reimporting 137 | from library using `import` with a query. 138 | 139 | Setup subsequent import directory of the following structure: 140 | 141 | testlib_dir/ 142 | New Tag Artist/ 143 | Tag Album/ 144 | Tag Title 1.mp3 145 | Tag Title 2.mp3 146 | Tag Title 3.mp3 147 | artifact.file 148 | artifact2.file 149 | """ 150 | config["filetote"]["extensions"] = ".file" 151 | 152 | log.debug("--- initial import") 153 | self._run_cli_command("import") 154 | 155 | self.lib.path_formats = [ 156 | ("default", os.path.join("New Tag Artist", "$album", "$title")), 157 | ] 158 | self._setup_import_session(query="artist", autotag=False, move=True) 159 | 160 | log.debug("--- second import") 161 | self._run_cli_command("import") 162 | 163 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album") 164 | self.assert_not_in_lib_dir(b"Tag Artist") 165 | self.assert_in_lib_dir(b"New Tag Artist", b"Tag Album", b"artifact.file") 166 | 167 | def test_prune_move_query(self) -> None: 168 | """Check that plugin prunes any remaining empty album folders when using 169 | the `move` with a query. 170 | 171 | Setup subsequent import directory of the following structure: 172 | 173 | testlib_dir/ 174 | New Tag Artist/ 175 | Tag Album/ 176 | Tag Title 1.mp3 177 | Tag Title 2.mp3 178 | Tag Title 3.mp3 179 | artifact.file 180 | artifact2.file 181 | """ 182 | config["filetote"]["extensions"] = ".file" 183 | 184 | log.debug("--- initial import") 185 | self._run_cli_command("import") 186 | 187 | self.lib.path_formats = [ 188 | ("default", os.path.join("New Tag Artist", "$album", "$title")), 189 | ] 190 | 191 | log.debug("--- run mover") 192 | self._run_cli_command("move", query="artist:'Tag Artist'") 193 | 194 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album") 195 | self.assert_not_in_lib_dir(b"Tag Artist") 196 | self.assert_in_lib_dir(b"New Tag Artist", b"Tag Album", b"artifact.file") 197 | 198 | def test_prune_modify_query(self) -> None: 199 | """Check that plugin prunes any remaining empty album folders when using 200 | the `modify` with a query. 201 | 202 | Setup subsequent import directory of the following structure: 203 | 204 | testlib_dir/ 205 | New Tag Artist/ 206 | Tag Album/ 207 | Tag Title 1.mp3 208 | Tag Title 2.mp3 209 | Tag Title 3.mp3 210 | artifact.file 211 | artifact2.file 212 | """ 213 | config["filetote"]["extensions"] = ".file" 214 | 215 | log.debug("--- initial import") 216 | self._run_cli_command("import") 217 | 218 | self.lib.path_formats = [ 219 | ("default", os.path.join("$artist", "$album", "$title")), 220 | ] 221 | 222 | log.debug("--- run modify") 223 | self._run_cli_command( 224 | "modify", query="artist:'Tag Artist'", mods={"artist": "New Tag Artist"} 225 | ) 226 | 227 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album") 228 | self.assert_not_in_lib_dir(b"Tag Artist") 229 | self.assert_in_lib_dir(b"New Tag Artist", b"Tag Album", b"artifact.file") 230 | -------------------------------------------------------------------------------- /tests/test_reimport.py: -------------------------------------------------------------------------------- 1 | """Tests reimporting for the beets-filetote plugin.""" 2 | 3 | import logging 4 | import os 5 | 6 | from typing import List, Optional 7 | 8 | from beets import config 9 | 10 | from tests.helper import FiletoteTestCase 11 | 12 | log = logging.getLogger("beets") 13 | 14 | 15 | class FiletoteReimportTest(FiletoteTestCase): 16 | """Tests to check that Filetote handles reimports correctly.""" 17 | 18 | def setUp(self, _other_plugins: Optional[List[str]] = None) -> None: 19 | """Setup subsequent import directory of the below structure. 20 | 21 | Ex: 22 | testlib_dir/ 23 | Tag Artist/ 24 | Tag Album/ 25 | Tag Title 1.mp3 26 | Tag Title 2.mp3 27 | Tag Title 3.mp3 28 | artifact.file 29 | artifact2.file 30 | """ 31 | super().setUp() 32 | 33 | self._create_flat_import_dir() 34 | self._setup_import_session(autotag=False, move=True) 35 | 36 | config["filetote"]["extensions"] = ".file" 37 | 38 | log.debug("--- initial import") 39 | self._run_cli_command("import") 40 | 41 | def test_reimport_artifacts_with_copy(self) -> None: 42 | """Tests that when reimporting, copying actually results in a move. The 43 | operation gets changed to `move` when the media file is already in the 44 | library (hence, reimport). 45 | """ 46 | # Cause files to relocate (move) when reimported 47 | self.lib.path_formats[0] = ( 48 | "default", 49 | os.path.join("1$artist", "$album", "$title"), 50 | ) 51 | self._setup_import_session(autotag=False, import_dir=self.lib_dir) 52 | 53 | log.debug("--- second import") 54 | self._run_cli_command("import") 55 | 56 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 57 | self.assert_in_lib_dir(b"1Tag Artist", b"Tag Album", b"artifact.file") 58 | 59 | def test_reimport_artifacts_with_move(self) -> None: 60 | """Tests that when reimporting, moving works.""" 61 | # Cause files to relocate when reimported 62 | self.lib.path_formats[0] = ( 63 | "default", 64 | os.path.join("1$artist", "$album", "$title"), 65 | ) 66 | self._setup_import_session(autotag=False, import_dir=self.lib_dir, move=True) 67 | 68 | log.debug("--- second import") 69 | self._run_cli_command("import") 70 | 71 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 72 | self.assert_in_lib_dir(b"1Tag Artist", b"Tag Album", b"artifact.file") 73 | 74 | def test_do_nothing_when_paths_do_not_change_with_copy_import(self) -> None: 75 | """Tests that when paths are the same (before/after), no action is 76 | taken for default `copy` action. 77 | """ 78 | self._setup_import_session(autotag=False, import_dir=self.lib_dir) 79 | 80 | log.debug("--- second import") 81 | self._run_cli_command("import") 82 | 83 | self.assert_number_of_files_in_dir(5, self.lib_dir, b"Tag Artist", b"Tag Album") 84 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 85 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact2.file") 86 | 87 | def test_do_nothing_when_paths_do_not_change_with_move_import(self) -> None: 88 | """Tests that when paths are the same (before/after), no action is 89 | taken for default `move` action. 90 | """ 91 | self._setup_import_session(autotag=False, import_dir=self.lib_dir, move=True) 92 | 93 | log.debug("--- second import") 94 | self._run_cli_command("import") 95 | 96 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 97 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact2.file") 98 | 99 | def test_rename_with_copy_reimport(self) -> None: 100 | """Tests that renaming during `copy` works even when reimporting.""" 101 | config["paths"]["ext:file"] = os.path.join("$albumpath", "$artist - $album") 102 | self._setup_import_session(autotag=False, import_dir=self.lib_dir) 103 | 104 | log.debug("--- second import") 105 | self._run_cli_command("import") 106 | 107 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 108 | self.assert_in_lib_dir( 109 | b"Tag Artist", b"Tag Album", b"Tag Artist - Tag Album.file" 110 | ) 111 | 112 | def test_rename_with_move_reimport(self) -> None: 113 | """Tests that renaming during `move` works even when reimporting.""" 114 | config["paths"]["ext:file"] = os.path.join("$albumpath", "$artist - $album") 115 | self._setup_import_session(autotag=False, import_dir=self.lib_dir, move=True) 116 | 117 | log.debug("--- second import") 118 | self._run_cli_command("import") 119 | 120 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 121 | self.assert_in_lib_dir( 122 | b"Tag Artist", b"Tag Album", b"Tag Artist - Tag Album.file" 123 | ) 124 | 125 | def test_rename_when_paths_do_not_change(self) -> None: 126 | """This test considers the situation where the path format for a file extension 127 | is changed and files already in the library are reimported and renamed to 128 | reflect the change. 129 | """ 130 | config["paths"]["ext:file"] = os.path.join("$albumpath", "$album") 131 | self._setup_import_session(autotag=False, import_dir=self.lib_dir, move=True) 132 | 133 | log.debug("--- second import") 134 | self._run_cli_command("import") 135 | 136 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 137 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"Tag Album.file") 138 | 139 | def test_multiple_reimport_artifacts_with_move(self) -> None: 140 | """Tests that multiple reimports work the same as the initial action or 141 | a single reimport. 142 | """ 143 | # Cause files to relocate when reimported 144 | self.lib.path_formats[0] = ( 145 | "default", 146 | os.path.join("1$artist", "$album", "$title"), 147 | ) 148 | self._setup_import_session(autotag=False, import_dir=self.lib_dir, move=True) 149 | config["paths"]["ext:file"] = "$albumpath/$old_filename - import I" 150 | 151 | log.debug("--- first import") 152 | self._run_cli_command("import") 153 | 154 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 155 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact2.file") 156 | self.assert_in_lib_dir( 157 | b"1Tag Artist", b"Tag Album", b"artifact - import I.file" 158 | ) 159 | self.assert_in_lib_dir( 160 | b"1Tag Artist", b"Tag Album", b"artifact2 - import I.file" 161 | ) 162 | 163 | log.debug("--- second import") 164 | self.lib.path_formats[0] = ( 165 | "default", 166 | os.path.join("2$artist", "$album", "$title"), 167 | ) 168 | self._setup_import_session(autotag=False, import_dir=self.lib_dir, move=True) 169 | config["paths"]["ext:file"] = "$albumpath/$old_filename I" 170 | self._run_cli_command("import") 171 | 172 | self.assert_not_in_lib_dir( 173 | b"1Tag Artist", b"Tag Album", b"artifact - import I.file" 174 | ) 175 | self.assert_not_in_lib_dir( 176 | b"1Tag Artist", b"Tag Album", b"artifact2 - import I.file" 177 | ) 178 | self.assert_in_lib_dir( 179 | b"2Tag Artist", b"Tag Album", b"artifact - import I I.file" 180 | ) 181 | self.assert_in_lib_dir( 182 | b"2Tag Artist", b"Tag Album", b"artifact2 - import I I.file" 183 | ) 184 | 185 | log.debug("--- third import") 186 | self.lib.path_formats[0] = ( 187 | "default", 188 | os.path.join("3$artist", "$album", "$title"), 189 | ) 190 | self._setup_import_session(autotag=False, import_dir=self.lib_dir, move=True) 191 | 192 | self._run_cli_command("import") 193 | 194 | self.assert_not_in_lib_dir( 195 | b"2Tag Artist", b"Tag Album", b"artifact - import I I.file" 196 | ) 197 | self.assert_not_in_lib_dir( 198 | b"2Tag Artist", b"Tag Album", b"artifact2 - import I I.file" 199 | ) 200 | self.assert_in_lib_dir( 201 | b"3Tag Artist", b"Tag Album", b"artifact - import I I I.file" 202 | ) 203 | self.assert_in_lib_dir( 204 | b"3Tag Artist", b"Tag Album", b"artifact2 - import I I I.file" 205 | ) 206 | 207 | def test_reimport_artifacts_with_query(self) -> None: 208 | """Tests that when reimporting, copying works.""" 209 | # Cause files to relocate (move) when reimported 210 | self.lib.path_formats = [ 211 | ("default", os.path.join("New Tag Artist", "$album", "$title")), 212 | ] 213 | self._setup_import_session(query="artist", autotag=False, move=True) 214 | 215 | log.debug("--- second import") 216 | self._run_cli_command("import") 217 | 218 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 219 | self.assert_in_lib_dir(b"New Tag Artist", b"Tag Album", b"artifact.file") 220 | -------------------------------------------------------------------------------- /tests/test_rename.py: -------------------------------------------------------------------------------- 1 | """Tests renaming for the beets-filetote plugin.""" 2 | 3 | # ruff: noqa: PLR0904 4 | 5 | from __future__ import annotations 6 | 7 | import logging 8 | import os 9 | 10 | import pytest 11 | 12 | from beets import config 13 | 14 | from tests.helper import FiletoteTestCase 15 | 16 | log = logging.getLogger("beets") 17 | 18 | 19 | class FiletoteRenameTest(FiletoteTestCase): 20 | """Tests to check that Filetote renames as expected for custom path 21 | formats (both by extension and filename). 22 | """ 23 | 24 | def setUp(self, _other_plugins: list[str] | None = None) -> None: 25 | """Provides shared setup for tests.""" 26 | super().setUp() 27 | 28 | self._create_flat_import_dir() 29 | self._setup_import_session(autotag=False) 30 | 31 | def test_rename_when_copying(self) -> None: 32 | """Tests that renaming works when copying.""" 33 | config["filetote"]["extensions"] = ".file" 34 | config["paths"]["ext:file"] = "$albumpath/$artist - $album" 35 | 36 | self._run_cli_command("import") 37 | 38 | self.assert_in_lib_dir( 39 | b"Tag Artist", b"Tag Album", b"Tag Artist - Tag Album.file" 40 | ) 41 | self.assert_in_import_dir(b"the_album", b"artifact.file") 42 | self.assert_in_import_dir(b"the_album", b"artifact2.file") 43 | 44 | def test_rename_when_moving(self) -> None: 45 | """Tests that renaming works when moving.""" 46 | config["filetote"]["extensions"] = ".file" 47 | config["paths"]["ext:file"] = "$albumpath/$artist - $album" 48 | config["import"]["move"] = True 49 | 50 | self._run_cli_command("import") 51 | 52 | self.assert_in_lib_dir( 53 | b"Tag Artist", b"Tag Album", b"Tag Artist - Tag Album.file" 54 | ) 55 | self.assert_not_in_import_dir(b"the_album", b"artifact.file") 56 | 57 | def test_rename_paired_ext(self) -> None: 58 | """Tests that the value of `medianame_new` populates in renaming.""" 59 | config["filetote"]["extensions"] = ".lrc" 60 | config["filetote"]["pairing"]["enabled"] = True 61 | config["paths"]["paired_ext:lrc"] = "$albumpath/$medianame_new" 62 | 63 | self._run_cli_command("import") 64 | 65 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.lrc") 66 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"Tag Title 1.lrc") 67 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"Tag Title 2.lrc") 68 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"Tag Title 3.lrc") 69 | 70 | def test_rename_paired_ext_does_not_conflict_with_ext(self) -> None: 71 | """Tests that paired path definitions work alongside `ext` ones.""" 72 | config["filetote"]["extensions"] = ".lrc" 73 | config["filetote"]["pairing"]["enabled"] = True 74 | config["paths"]["ext:lrc"] = "$albumpath/1 $old_filename" 75 | config["paths"]["paired_ext:lrc"] = "$albumpath/$medianame_new" 76 | 77 | self._run_cli_command("import") 78 | 79 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"1 artifact.lrc") 80 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"Tag Title 1.lrc") 81 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"Tag Title 2.lrc") 82 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"Tag Title 3.lrc") 83 | 84 | def test_rename_paired_ext_is_prioritized_over_ext(self) -> None: 85 | """Tests that paired path definitions supersede `ext` ones when there's 86 | a collision. 87 | """ 88 | config["filetote"]["extensions"] = ".lrc" 89 | config["filetote"]["pairing"]["enabled"] = True 90 | config["paths"]["paired_ext:lrc"] = "$albumpath/$medianame_new" 91 | config["paths"]["ext:lrc"] = "$albumpath/1 $old_filename" 92 | 93 | self._run_cli_command("import") 94 | 95 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"1 artifact.lrc") 96 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"Tag Title 1.lrc") 97 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"Tag Title 2.lrc") 98 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"Tag Title 3.lrc") 99 | 100 | def test_rename_filename_is_prioritized_over_paired_ext(self) -> None: 101 | """Tests that filename path definitions supersede `paired` ones when there's 102 | a collision. 103 | """ 104 | config["filetote"]["extensions"] = ".lrc" 105 | config["filetote"]["pairing"]["enabled"] = True 106 | config["paths"]["paired_ext:lrc"] = "$albumpath/$medianame_new" 107 | config["paths"]["filename:track_1.lrc"] = "$albumpath/1 $old_filename" 108 | 109 | self._run_cli_command("import") 110 | 111 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.lrc") 112 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"1 track_1.lrc") 113 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"Tag Title 2.lrc") 114 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"Tag Title 3.lrc") 115 | 116 | def test_rename_period_is_optional_for_ext(self) -> None: 117 | """Tests that leading periods are options when definiting `ext` paths.""" 118 | config["filetote"]["extensions"] = ".file .nfo" 119 | config["paths"]["ext:file"] = "$albumpath/$artist - $album" 120 | config["paths"]["ext:.nfo"] = "$albumpath/$artist - $album 2" 121 | config["import"]["move"] = True 122 | 123 | self._run_cli_command("import") 124 | 125 | self.assert_in_lib_dir( 126 | b"Tag Artist", b"Tag Album", b"Tag Artist - Tag Album.file" 127 | ) 128 | self.assert_in_lib_dir( 129 | b"Tag Artist", b"Tag Album", b"Tag Artist - Tag Album 2.nfo" 130 | ) 131 | self.assert_not_in_import_dir(b"the_album", b"artifact.file") 132 | self.assert_not_in_import_dir(b"the_album", b"artifact.nfo") 133 | 134 | def test_rename_ignores_file_when_name_conflicts(self) -> None: 135 | """Ensure that if there are multiple files that would rename to the 136 | exact same name, that only the first is renamed (moved/copied/etc.) 137 | but not subsequent ones that conflict. 138 | """ 139 | config["filetote"]["extensions"] = ".file" 140 | config["paths"]["ext:file"] = "$albumpath/$artist - $album" 141 | config["import"]["move"] = True 142 | 143 | self._run_cli_command("import") 144 | 145 | # `artifact.file` correctly renames. 146 | self.assert_not_in_import_dir(b"the_album", b"artifact.file") 147 | self.assert_in_lib_dir( 148 | b"Tag Artist", b"Tag Album", b"Tag Artist - Tag Album.file" 149 | ) 150 | 151 | # `artifact2.file` will not rename since the destination filename conflicts with 152 | # `artifact.file` 153 | self.assert_in_import_dir(b"the_album", b"artifact2.file") 154 | 155 | def test_rename_multiple_extensions(self) -> None: 156 | """Ensure that specifying multiple extensions and definitions properly 157 | rename. 158 | """ 159 | config["filetote"]["extensions"] = ".file .nfo" 160 | config["paths"]["ext:file"] = "$albumpath/$artist - $album" 161 | config["paths"]["ext:nfo"] = "$albumpath/$artist - $album" 162 | config["import"]["move"] = True 163 | 164 | self._run_cli_command("import") 165 | 166 | self.assert_in_lib_dir( 167 | b"Tag Artist", b"Tag Album", b"Tag Artist - Tag Album.file" 168 | ) 169 | self.assert_in_lib_dir( 170 | b"Tag Artist", b"Tag Album", b"Tag Artist - Tag Album.nfo" 171 | ) 172 | self.assert_not_in_import_dir(b"the_album", b"artifact.file") 173 | self.assert_not_in_import_dir(b"the_album", b"artifact.nfo") 174 | # `artifact2.file` will rename since the destination filename conflicts with 175 | # `artifact.file` 176 | self.assert_in_import_dir(b"the_album", b"artifact2.file") 177 | 178 | def test_rename_matching_filename(self) -> None: 179 | """Ensure that `filename` path definitions rename correctly.""" 180 | config["filetote"]["filenames"] = "artifact.file artifact2.file" 181 | config["paths"]["filename:artifact.file"] = "$albumpath/new-filename" 182 | config["paths"]["filename:artifact2.file"] = "$albumpath/another-new-filename" 183 | config["import"]["move"] = True 184 | 185 | self._run_cli_command("import") 186 | 187 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"new-filename.file") 188 | self.assert_in_lib_dir( 189 | b"Tag Artist", b"Tag Album", b"another-new-filename.file" 190 | ) 191 | self.assert_not_in_import_dir(b"the_album", b"artifact.file") 192 | self.assert_not_in_import_dir(b"the_album", b"artifact2.file") 193 | 194 | def test_rename_prioritizes_filename_over_ext(self) -> None: 195 | """Tests that filename path definitions supersede `ext` ones when there's 196 | a collision. 197 | """ 198 | config["filetote"]["extensions"] = ".file" 199 | config["filetote"]["filenames"] = "artifact.file" 200 | config["paths"]["ext:file"] = "$albumpath/$artist - $old_filename" 201 | config["paths"]["filename:artifact.file"] = "$albumpath/new-filename" 202 | config["import"]["move"] = True 203 | 204 | self._run_cli_command("import") 205 | 206 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"new-filename.file") 207 | self.assert_in_lib_dir( 208 | b"Tag Artist", b"Tag Album", b"Tag Artist - artifact2.file" 209 | ) 210 | 211 | self.assert_not_in_import_dir(b"the_album", b"artifact1.file") 212 | self.assert_not_in_import_dir(b"the_album", b"artifact2.file") 213 | 214 | def test_rename_prioritizes_filename_over_ext_reversed(self) -> None: 215 | """Ensure the order of path definitions does not effect the priority 216 | order. 217 | """ 218 | config["filetote"]["extensions"] = ".file" 219 | config["filetote"]["filenames"] = "artifact.file" 220 | # order of paths matter here; this is the opposite order as 221 | # `test_rename_prioritizes_filename_over_ext` 222 | config["paths"]["filename:artifact.file"] = "$albumpath/new-filename" 223 | config["paths"]["ext:file"] = "$albumpath/$artist - $old_filename" 224 | config["import"]["move"] = True 225 | 226 | self._run_cli_command("import") 227 | 228 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"new-filename.file") 229 | self.assert_in_lib_dir( 230 | b"Tag Artist", b"Tag Album", b"Tag Artist - artifact2.file" 231 | ) 232 | 233 | self.assert_not_in_import_dir(b"the_album", b"artifact1.file") 234 | self.assert_not_in_import_dir(b"the_album", b"artifact2.file") 235 | 236 | def test_rename_multiple_files_prioritizes_filename_over_ext(self) -> None: 237 | """Tests that multiple filename path definitions still supersede `ext` 238 | ones when there's collision(s). 239 | """ 240 | config["filetote"]["extensions"] = ".file" 241 | config["filetote"]["filenames"] = "artifact.file artifact2.file" 242 | config["paths"]["ext:file"] = "$albumpath/$artist - $old_filename" 243 | config["paths"]["filename:artifact.file"] = "$albumpath/new-filename" 244 | config["paths"]["filename:artifact2.file"] = "$albumpath/new-filename2" 245 | config["import"]["move"] = True 246 | 247 | self._run_cli_command("import") 248 | 249 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"new-filename.file") 250 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"new-filename2.file") 251 | 252 | self.assert_not_in_import_dir(b"the_album", b"artifact1.file") 253 | self.assert_not_in_import_dir(b"the_album", b"artifact2.file") 254 | 255 | def test_rename_wildcard_extension_halts(self) -> None: 256 | """Ensure that specifying `ext:.*` extensions results in an exception.""" 257 | config["filetote"]["extensions"] = ".file .nfo" 258 | config["paths"]["ext:.*"] = "$albumpath/$old_filename" 259 | config["import"]["move"] = True 260 | 261 | with pytest.raises(AssertionError) as assert_test_message: 262 | self._run_cli_command("import") 263 | 264 | assertion_msg: str = ( 265 | "Error: path query `ext:.*` is not valid. If you are" 266 | " trying to set a default/fallback, please use `filetote:default` instead." 267 | ) 268 | 269 | assert str(assert_test_message.value) == assertion_msg 270 | 271 | def test_rename_filetote_paths_wildcard_extension_halts(self) -> None: 272 | """Ensure that specifying `ext:.*` extensions results in an exception.""" 273 | config["filetote"]["extensions"] = ".file .nfo" 274 | config["filetote"]["paths"]["ext:.*"] = "$albumpath/$old_filename" 275 | config["import"]["move"] = True 276 | 277 | with pytest.raises(AssertionError) as assert_test_message: 278 | self._run_cli_command("import") 279 | 280 | assertion_msg: str = ( 281 | "Error: path query `ext:.*` is not valid. If you are" 282 | " trying to set a default/fallback, please use `filetote:default` instead." 283 | ) 284 | 285 | assert str(assert_test_message.value) == assertion_msg 286 | 287 | def test_filetote_paths_priority_over_beets_paths(self) -> None: 288 | """Ensure that the Filetote `paths` settings take priority over 289 | any matching-specified ones in Beets' `paths` settings. 290 | """ 291 | config["filetote"]["extensions"] = ".file" 292 | 293 | config["filetote"]["paths"]["filetote:default"] = os.path.join( 294 | "$albumpath", "Filetote", "$old_filename" 295 | ) 296 | config["paths"]["filetote:default"] = os.path.join( 297 | "$albumpath", "Beets", "$old_filename" 298 | ) 299 | 300 | config["import"]["move"] = True 301 | 302 | self._run_cli_command("import") 303 | 304 | self.assert_in_lib_dir( 305 | b"Tag Artist", b"Tag Album", b"Filetote", b"artifact.file" 306 | ) 307 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 308 | 309 | def test_rename_filetote_default(self) -> None: 310 | """Ensure that the default value for a path query of an otherwise not specified 311 | artifact is `$albumpath/$old_filename`. 312 | """ 313 | config["filetote"]["extensions"] = ".file" 314 | config["import"]["move"] = True 315 | 316 | self._run_cli_command("import") 317 | 318 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 319 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact2.file") 320 | 321 | def test_rename_filetote_custom_default(self) -> None: 322 | """Ensure that the default value for a path query for artifacts 323 | (`filetote:default`) can be updated via the root `paths` setting. 324 | """ 325 | config["filetote"]["extensions"] = ".file" 326 | 327 | config["paths"]["filetote:default"] = os.path.join( 328 | "$albumpath", "New", "$old_filename" 329 | ) 330 | 331 | config["import"]["move"] = True 332 | 333 | self._run_cli_command("import") 334 | 335 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"New", b"artifact.file") 336 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 337 | 338 | def test_rename_filetote_custom_default_filetote_paths(self) -> None: 339 | """Ensure that the default value for a path query for artifacts 340 | (`filetote:default`) can be updated via the Filetote `paths` setting. 341 | """ 342 | config["filetote"]["extensions"] = ".file" 343 | 344 | config["filetote"]["paths"]["filetote:default"] = os.path.join( 345 | "$albumpath", "New", "$old_filename" 346 | ) 347 | 348 | config["import"]["move"] = True 349 | 350 | self._run_cli_command("import") 351 | 352 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"New", b"artifact.file") 353 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 354 | 355 | def test_rename_filetote_custom_default_priority(self) -> None: 356 | """Ensure that the default value for a path query for artifacts 357 | (`filetote:default`) prioritizes the Filetote `paths` setting over the 358 | root `paths` setting. 359 | """ 360 | config["filetote"]["extensions"] = ".file" 361 | 362 | config["paths"]["filetote:default"] = os.path.join( 363 | "$albumpath", "Paths", "$old_filename" 364 | ) 365 | config["filetote"]["paths"]["filetote:default"] = os.path.join( 366 | "$albumpath", "Filetote", "$old_filename" 367 | ) 368 | 369 | config["import"]["move"] = True 370 | 371 | self._run_cli_command("import") 372 | 373 | self.assert_in_lib_dir( 374 | b"Tag Artist", b"Tag Album", b"Filetote", b"artifact.file" 375 | ) 376 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 377 | 378 | def test_rename_respect_defined_order(self) -> None: 379 | """Tests that patterns of just folders grab all contents.""" 380 | import_dir = os.path.join(self.import_dir, b"the_album") 381 | scans_dir = os.path.join(self.import_dir, b"the_album", b"scans") 382 | 383 | os.makedirs(scans_dir) 384 | 385 | self.create_file( 386 | path=scans_dir, 387 | filename=b"scan-1.jpg", 388 | ) 389 | 390 | self.create_file( 391 | path=import_dir, 392 | filename=b"cover.jpg", 393 | ) 394 | 395 | self.create_file( 396 | path=import_dir, 397 | filename=b"sub.cue", 398 | ) 399 | 400 | self.create_file( 401 | path=import_dir, 402 | filename=b"md5.sum", 403 | ) 404 | 405 | config["filetote"]["extensions"] = ".*" 406 | config["filetote"]["exclude"] = {"extensions": [".sum"]} 407 | 408 | config["filetote"]["patterns"] = { 409 | "scans": ["[sS]cans/"], 410 | "artwork": ["[sS]cans/"], 411 | "cover": ["*.jpg"], 412 | "cue": ["*.cue"], 413 | } 414 | 415 | config["paths"] = { 416 | "pattern:cover": os.path.join( 417 | "$albumpath", "${album} - $old_filename - cover" 418 | ), 419 | "filetote:default": os.path.join("$albumpath", "default", "$old_filename"), 420 | "pattern:cue": os.path.join("$albumpath", "${album} - $old_filename - cue"), 421 | } 422 | 423 | config["filetote"]["paths"] = { 424 | "pattern:artwork": os.path.join("$albumpath", "$old_filename - artwork"), 425 | "pattern:scans": os.path.join("$albumpath", "scans", "$old_filename"), 426 | } 427 | 428 | self._run_cli_command("import") 429 | 430 | self.assert_not_in_lib_dir(b"Tag Artist", b"Tag Album", b"Artwork", b"md5.sum") 431 | 432 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"scans", b"scan-1.jpg") 433 | self.assert_in_lib_dir( 434 | b"Tag Artist", b"Tag Album", b"Tag Album - sub - cue.cue" 435 | ) 436 | self.assert_in_lib_dir( 437 | b"Tag Artist", b"Tag Album", b"Tag Album - cover - cover.jpg" 438 | ) 439 | -------------------------------------------------------------------------------- /tests/test_rename_convert_plugin.py: -------------------------------------------------------------------------------- 1 | """Tests that renaming using `item_fields` work as expected, when the 2 | `inline` plugin is loaded. 3 | """ 4 | 5 | import logging 6 | import os 7 | 8 | from typing import List, Optional 9 | 10 | from beets import config 11 | 12 | from tests.helper import FiletoteTestCase, MediaSetup 13 | 14 | log = logging.getLogger("beets") 15 | 16 | 17 | class FiletoteConvertRenameTest(FiletoteTestCase): 18 | """Tests that renaming using `item_fields` work as expected, when the 19 | `convert` plugin is loaded. 20 | """ 21 | 22 | def setUp(self, _other_plugins: Optional[List[str]] = None) -> None: 23 | """Provides shared setup for tests.""" 24 | super().setUp(other_plugins=["convert"]) 25 | 26 | def test_rename_works_with_inline_plugin(self) -> None: 27 | """Ensure that Filetote can find artifacts as expected with the `convert` 28 | plugin is enabled. 29 | """ 30 | media_file_list = [ 31 | MediaSetup(file_type="wav", count=1), 32 | ] 33 | 34 | self._create_flat_import_dir(media_files=media_file_list) 35 | self._setup_import_session(autotag=False) 36 | 37 | config["filetote"]["extensions"] = ".*" 38 | 39 | temp_convert_dir = os.path.join(self.temp_dir, b"temp_convert_dir") 40 | os.makedirs(temp_convert_dir) 41 | 42 | config["convert"] = { 43 | "auto": True, 44 | "dest": os.path.join(self.lib_dir, b"Tag Artist", b"Tag Album"), 45 | "copy_album_art": True, 46 | "delete_originals": False, 47 | "format": "flac", 48 | "never_convert_lossy_files": False, 49 | "tmpdir": temp_convert_dir, 50 | "quiet": False, 51 | } 52 | 53 | self._run_cli_command("import") 54 | 55 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 56 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"Tag Title 1.flac") 57 | -------------------------------------------------------------------------------- /tests/test_rename_filetote_fields.py: -------------------------------------------------------------------------------- 1 | """Tests renaming Filetote custom fields for the beets-filetote plugin.""" 2 | 3 | import logging 4 | import os 5 | 6 | from typing import List, Optional 7 | 8 | from beets import config 9 | 10 | from tests.helper import FiletoteTestCase 11 | 12 | log = logging.getLogger("beets") 13 | 14 | 15 | class FiletoteRenameFiletoteFieldsTest(FiletoteTestCase): 16 | """Tests to check that Filetote renames using Filetote-provided fields as 17 | expected for custom path formats. 18 | """ 19 | 20 | def setUp(self, _other_plugins: Optional[List[str]] = None) -> None: 21 | """Provides shared setup for tests.""" 22 | super().setUp() 23 | 24 | self._create_flat_import_dir(pair_subfolders=True) 25 | self._setup_import_session(autotag=False, move=True) 26 | 27 | def test_rename_field_albumpath(self) -> None: 28 | """Tests that the value of `albumpath` populates in renaming.""" 29 | config["filetote"]["extensions"] = ".file" 30 | config["paths"]["ext:file"] = "$albumpath/newname" 31 | 32 | self._run_cli_command("import") 33 | 34 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"newname.file") 35 | 36 | def test_rename_field_old_filename(self) -> None: 37 | """Tests that the value of `old_filename` populates in renaming.""" 38 | config["filetote"]["extensions"] = ".file" 39 | config["paths"]["ext:file"] = "$albumpath/$old_filename" 40 | 41 | self._run_cli_command("import") 42 | 43 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact.file") 44 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"artifact2.file") 45 | 46 | def test_rename_field_medianame_old(self) -> None: 47 | """Tests that the value of `medianame_old` populates in renaming.""" 48 | config["filetote"]["extensions"] = ".file" 49 | config["paths"]["ext:file"] = "$albumpath/$medianame_old" 50 | 51 | self._run_cli_command("import") 52 | 53 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"track_1.file") 54 | 55 | def test_rename_field_medianame_new(self) -> None: 56 | """Tests that the value of `medianame_new` populates in renaming.""" 57 | config["filetote"]["extensions"] = ".lrc" 58 | config["filetote"]["pairing"] = { 59 | "enabled": True, 60 | "pairing_only": True, 61 | } 62 | config["paths"]["ext:lrc"] = "$albumpath/$medianame_new" 63 | 64 | self._run_cli_command("import") 65 | 66 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"Tag Title 1.lrc") 67 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"Tag Title 2.lrc") 68 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"Tag Title 3.lrc") 69 | 70 | def test_rename_field_subpath(self) -> None: 71 | """Tests that the value of `subpath` populates in renaming. Also tests that the 72 | default lyric file moves as expected without a trailing pah separator. 73 | """ 74 | config["filetote"]["extensions"] = ".lrc" 75 | config["filetote"]["pairing"]["enabled"] = True 76 | 77 | config["paths"]["ext:lrc"] = os.path.join( 78 | "$albumpath", "$subpath$medianame_new" 79 | ) 80 | 81 | self._run_cli_command("import") 82 | 83 | self.assert_in_lib_dir( 84 | b"Tag Artist", 85 | b"Tag Album", 86 | b"lyrics", 87 | b"lyric-subfolder", 88 | b"Tag Title 1.lrc", 89 | ) 90 | self.assert_in_lib_dir( 91 | b"Tag Artist", 92 | b"Tag Album", 93 | b"lyrics", 94 | b"lyric-subfolder", 95 | b"Tag Title 2.lrc", 96 | ) 97 | self.assert_in_lib_dir( 98 | b"Tag Artist", 99 | b"Tag Album", 100 | b"lyrics", 101 | b"lyric-subfolder", 102 | b"Tag Title 3.lrc", 103 | ) 104 | -------------------------------------------------------------------------------- /tests/test_rename_inline_plugin.py: -------------------------------------------------------------------------------- 1 | """Tests that renaming using `item_fields` work as expected, when the 2 | `inline` plugin is loaded. 3 | """ 4 | 5 | import logging 6 | import os 7 | 8 | from typing import List, Optional 9 | 10 | from beets import config 11 | 12 | from tests.helper import FiletoteTestCase 13 | 14 | log = logging.getLogger("beets") 15 | 16 | 17 | class FiletoteInlineRenameTest(FiletoteTestCase): 18 | """Tests that renaming using `item_fields` work as expected, when the 19 | `inline` plugin is loaded. 20 | """ 21 | 22 | def setUp(self, _other_plugins: Optional[List[str]] = None) -> None: 23 | """Provides shared setup for tests.""" 24 | super().setUp(other_plugins=["inline"]) 25 | 26 | def test_rename_works_with_inline_plugin(self) -> None: 27 | """Ensure that Filetote can rename fields as expected with the `inline` 28 | plugin is enabled. 29 | """ 30 | self._create_flat_import_dir() 31 | self._setup_import_session(autotag=False) 32 | 33 | config["filetote"]["extensions"] = ".*" 34 | config["filetote"]["patterns"] = { 35 | "file-pattern": ["*.file"], 36 | } 37 | config["paths"]["ext:file"] = ( 38 | "$albumpath/%if{$multidisc,Disc $disc} - $old_filename" 39 | ) 40 | 41 | config["item_fields"]["multidisc"] = "1 if disctotal > 1 else 0" 42 | 43 | self.lib.path_formats[0] = ( 44 | "default", 45 | os.path.join("$artist", "$album", "%if{$multidisc,Disc $disc/}$title"), 46 | ) 47 | 48 | self._run_cli_command("import") 49 | 50 | self.assert_in_lib_dir( 51 | b"Tag Artist", b"Tag Album", b"Disc 01", b"Disc 01 - artifact.file" 52 | ) 53 | -------------------------------------------------------------------------------- /tests/test_rename_item_fields.py: -------------------------------------------------------------------------------- 1 | """Tests renaming Item fields for the beets-filetote plugin.""" 2 | 3 | import logging 4 | 5 | from typing import List, Optional 6 | 7 | from beets import config 8 | 9 | from tests.helper import FiletoteTestCase 10 | 11 | log = logging.getLogger("beets") 12 | 13 | 14 | class FiletoteRenameItemFieldsTest(FiletoteTestCase): 15 | """Tests to check that Filetote renames using default Item fields as 16 | expected for custom path formats. 17 | """ 18 | 19 | def setUp(self, _other_plugins: Optional[List[str]] = None) -> None: 20 | """Provides shared setup for tests.""" 21 | super().setUp() 22 | 23 | self._create_flat_import_dir() 24 | self._setup_import_session(autotag=False) 25 | 26 | def test_rename_core_item_fields(self) -> None: 27 | """Tests that the value of `title, `artist`, `albumartist`, and `album` 28 | populate in renaming. 29 | """ 30 | config["filetote"]["extensions"] = ".file" 31 | config["paths"]["ext:file"] = ( 32 | "$albumpath/$artist - $album - $track $title ($albumartist) newname" 33 | ) 34 | 35 | self._run_cli_command("import") 36 | 37 | self.assert_in_lib_dir( 38 | b"Tag Artist", 39 | b"Tag Album", 40 | b"Tag Artist - Tag Album - 01 Tag Title 1 (Tag Album Artist) newname.file", 41 | ) 42 | 43 | def test_rename_other_meta_item_fields(self) -> None: 44 | """Tests that the value of `year, `month`, `day`, `$track, `tracktotal` 45 | and `disc`, and `disctotal` populate in renaming. 46 | """ 47 | config["filetote"]["extensions"] = ".file" 48 | config["paths"]["ext:file"] = ( 49 | "$albumpath/($year-$month-$day) - Track $track of $tracktotal - Disc $disc" 50 | " of $disctotal" 51 | ) 52 | 53 | self._run_cli_command("import") 54 | 55 | self.assert_in_lib_dir( 56 | b"Tag Artist", 57 | b"Tag Album", 58 | b"(2023-02-03) - Track 01 of 05 - Disc 01 of 07.file", 59 | ) 60 | 61 | def test_rename_lyric_comment_item_fields(self) -> None: 62 | """Tests that the value of `lyric` and `comments` populate in renaming.""" 63 | config["filetote"]["extensions"] = ".file" 64 | config["paths"]["ext:file"] = "$albumpath/$lyrics ($comments)" 65 | 66 | self._run_cli_command("import") 67 | 68 | self.assert_in_lib_dir( 69 | b"Tag Artist", 70 | b"Tag Album", 71 | b"Tag lyrics (Tag comments).file", 72 | ) 73 | 74 | def test_rename_track_music_item_fields(self) -> None: 75 | """Tests that the value of `bpm`, `length`, `format`, and `bitrate` populate 76 | in renaming. 77 | 78 | `length` will convert from `M:SS` to `M_SS` for path-friendliness. 79 | """ 80 | config["filetote"]["extensions"] = ".file" 81 | config["paths"]["ext:file"] = ( 82 | "$albumpath/newname - ${bpm}bpm $length ($format) ($bitrate)" 83 | ) 84 | 85 | self._run_cli_command("import") 86 | 87 | self.assert_in_lib_dir( 88 | b"Tag Artist", 89 | b"Tag Album", 90 | b"newname - 8bpm 0_01 (MP3) (80kbps).file", 91 | ) 92 | 93 | def test_rename_mb_item_fields(self) -> None: 94 | """Tests that the value of `mb_albumid, `mb_artistid`, 95 | `mb_albumartistid`, `mb_trackid`, `mb_releasetrackid`, 96 | and `mb_workid` populate in renaming. 97 | """ 98 | config["filetote"]["extensions"] = ".file" 99 | config["paths"]["ext:file"] = ( 100 | "$albumpath/$mb_artistid - $mb_albumid ($mb_albumartistid) - $mb_trackid" 101 | " $mb_releasetrackid - $mb_workid" 102 | ) 103 | 104 | self._run_cli_command("import") 105 | 106 | self.assert_in_lib_dir( 107 | b"Tag Artist", 108 | b"Tag Album", 109 | b"someID-3 - someID-2 (someID-4) - someID-1 someID-5 - Tag work" 110 | b" musicbrainz id.file", 111 | ) 112 | -------------------------------------------------------------------------------- /tests/test_rename_paths.py: -------------------------------------------------------------------------------- 1 | """Tests renaming based on paths for the beets-filetote plugin.""" 2 | 3 | import logging 4 | 5 | from typing import List, Optional 6 | 7 | from beets import config 8 | 9 | from tests.helper import FiletoteTestCase, capture_log 10 | 11 | log = logging.getLogger("beets") 12 | 13 | 14 | class FiletoteRenamePathsTest(FiletoteTestCase): 15 | """Tests to check that Filetote renames using custom path formats configured 16 | either in the `paths` scetion of the overall config or in Filetote's. 17 | """ 18 | 19 | def setUp(self, _other_plugins: Optional[List[str]] = None) -> None: 20 | """Provides shared setup for tests.""" 21 | super().setUp() 22 | 23 | self._create_flat_import_dir() 24 | self._setup_import_session(autotag=False) 25 | 26 | def test_rename_using_filetote_path_when_copying(self) -> None: 27 | """Tests that renaming works using setting from Filetote's paths.""" 28 | config["filetote"]["extensions"] = ".file .nfo" 29 | config["filetote"]["paths"] = { 30 | "ext:file": "$albumpath/$artist - $album", 31 | "ext:nfo": "$albumpath/$artist - $album", 32 | } 33 | 34 | self._run_cli_command("import") 35 | 36 | self.assert_in_lib_dir( 37 | b"Tag Artist", b"Tag Album", b"Tag Artist - Tag Album.file" 38 | ) 39 | self.assert_in_lib_dir( 40 | b"Tag Artist", b"Tag Album", b"Tag Artist - Tag Album.nfo" 41 | ) 42 | 43 | def test_rename_using_filetote_path_pattern_optional(self) -> None: 44 | """Tests that renaming patterns works using setting from Filetote's paths 45 | doesn't require `pattern:` prefix. 46 | """ 47 | config["filetote"]["patterns"] = { 48 | "file-pattern": ["[Aa]rtifact.file"], 49 | "nfo-pattern": ["*.nfo"], 50 | } 51 | config["filetote"]["paths"] = { 52 | "pattern:file-pattern": "$albumpath/file-pattern $old_filename", 53 | "nfo-pattern": "$albumpath/nfo-pattern $old_filename", 54 | } 55 | 56 | with capture_log() as logs: 57 | self._run_cli_command("import") 58 | 59 | for line in logs: 60 | if line.startswith("filetote:"): 61 | log.info(line) 62 | 63 | self.assert_in_lib_dir( 64 | b"Tag Artist", b"Tag Album", b"file-pattern artifact.file" 65 | ) 66 | self.assert_in_lib_dir(b"Tag Artist", b"Tag Album", b"nfo-pattern artifact.nfo") 67 | 68 | def test_rename_prioritizes_filetote_path(self) -> None: 69 | """Tests that renaming patterns works using setting from Filetote's paths 70 | doesn't require `pattern:` prefix. 71 | """ 72 | config["filetote"]["patterns"] = { 73 | "file-pattern": ["[Aa]rtifact.file"], 74 | "nfo-pattern": ["*.nfo"], 75 | } 76 | config["paths"] = { 77 | "pattern:file-pattern": "$albumpath/beets_path $old_filename", 78 | "nfo-pattern": "$albumpath/beets_path $old_filename", 79 | } 80 | config["filetote"]["paths"] = { 81 | "pattern:file-pattern": "$albumpath/filetote_path $old_filename", 82 | "nfo-pattern": "$albumpath/filetote_path $old_filename", 83 | } 84 | 85 | with capture_log() as logs: 86 | self._run_cli_command("import") 87 | 88 | for line in logs: 89 | if line.startswith("filetote:"): 90 | log.info(line) 91 | 92 | self.assert_in_lib_dir( 93 | b"Tag Artist", b"Tag Album", b"filetote_path artifact.file" 94 | ) 95 | self.assert_in_lib_dir( 96 | b"Tag Artist", b"Tag Album", b"filetote_path artifact.nfo" 97 | ) 98 | -------------------------------------------------------------------------------- /tests/test_version.py: -------------------------------------------------------------------------------- 1 | """Tests that the version specified for the plugin matches the value in pyproject.""" 2 | 3 | from typing import List, Optional 4 | 5 | import beetsplug 6 | import toml # type: ignore 7 | 8 | from tests.helper import FiletoteTestCase 9 | 10 | 11 | class FiletoteVersionTest(FiletoteTestCase): 12 | """Tests that the version specified for the plugin matches the value in 13 | pyproject. 14 | """ 15 | 16 | def setUp(self, _other_plugins: Optional[List[str]] = None) -> None: 17 | """Provides shared setup for tests.""" 18 | super().setUp() 19 | 20 | def test_version_matches(self) -> None: 21 | """Ensure that the Filetote version is properly reflected in the right 22 | areas. 23 | """ 24 | plugin_version = beetsplug.__version__ 25 | 26 | with open("./pyproject.toml", encoding="utf-8") as pyproject_file: 27 | data = toml.load(pyproject_file) 28 | 29 | toml_version = data["tool"]["poetry"]["version"] 30 | 31 | assert plugin_version == toml_version 32 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit and function tests for the beets-filetote plugin.""" 2 | -------------------------------------------------------------------------------- /tests/unit/test_filetote_dataclasses.py: -------------------------------------------------------------------------------- 1 | """Test for functions in `filetote_dataclasses`, esp. TypeError validations.""" 2 | # ruff: noqa: SLF001 3 | 4 | from __future__ import annotations 5 | 6 | import os 7 | import sys 8 | import unittest 9 | 10 | from typing import Any 11 | 12 | import pytest 13 | 14 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) 15 | 16 | from beetsplug import ( 17 | filetote_dataclasses, 18 | ) 19 | 20 | 21 | class TestTypeErrorFunctions(unittest.TestCase): 22 | """Test for functions in `filetote_dataclasses`, esp. TypeError validations.""" 23 | 24 | def test__validate_types_instance(self) -> None: 25 | """Ensure the instance function correctly checks for the types.""" 26 | # Ensure basic type check 27 | self._test_instance_validation(["test"], {}, dict, str) 28 | 29 | # Ensure Class type checks 30 | pairing_dataclass = filetote_dataclasses.FiletotePairingData() 31 | 32 | self._test_instance_validation( 33 | ["test"], 34 | pairing_dataclass, 35 | filetote_dataclasses.FiletotePairingData, 36 | filetote_dataclasses.FiletoteConfig, 37 | ) 38 | 39 | def _test_instance_validation( 40 | self, 41 | field_name: list[str], 42 | field_value: Any, 43 | field_type: Any, 44 | expected_type: Any, 45 | ) -> None: 46 | """Helper Function to test that the instance function correctly checks for the 47 | types. 48 | """ 49 | try: 50 | filetote_dataclasses._validate_types_instance( 51 | field_name, field_value, field_type 52 | ) 53 | except TypeError as e: 54 | self.fail(f"Exception {type(e)} was raised unexpectedly: {e}") 55 | 56 | with pytest.raises(TypeError) as assert_test_message: 57 | filetote_dataclasses._validate_types_instance( 58 | field_name, field_value, expected_type 59 | ) 60 | 61 | assertion_msg: str = ( 62 | "Value for Filetote config key" 63 | f' "{filetote_dataclasses._format_config_hierarchy(field_name)}" should be' 64 | f" of type {expected_type}, got `{type(field_value)}`" 65 | ) 66 | 67 | assert str(assert_test_message.value) == assertion_msg 68 | 69 | def test__validate_types_dict(self) -> None: 70 | """Ensure the dict function correctly checks for the types.""" 71 | # Test the positive outcome of dict comparison 72 | try: 73 | filetote_dataclasses._validate_types_dict(["test"], {"key": "value"}, str) 74 | except TypeError as e: 75 | self.fail(f"Exception {type(e)} was raised unexpectedly: {e}") 76 | 77 | # Fail if the key isn't a string 78 | with pytest.raises(TypeError) as non_string_key_test: 79 | filetote_dataclasses._validate_types_dict(["test"], {123: "value"}, str) 80 | 81 | assert ( 82 | str(non_string_key_test.value) 83 | == 'Key "123" for Filetote config key "[test]" should be of type string' 84 | " (`str`), got ``" 85 | ) 86 | 87 | # Fail the value doesn't match the type 88 | with pytest.raises(TypeError) as wrong_value_type_test: 89 | filetote_dataclasses._validate_types_dict(["test"], {"key": []}, str) 90 | 91 | assert ( 92 | str(wrong_value_type_test.value) 93 | == 'Key "key"\'s Value for Filetote config key "[test]" should be of type' 94 | " string (`str`), got ``" 95 | ) 96 | 97 | # Fail the the inner list value isn't a string 98 | with pytest.raises(TypeError) as wrong_value_type_test: 99 | filetote_dataclasses._validate_types_dict( 100 | ["test"], {"key": [123]}, list, list_subtype=str 101 | ) 102 | 103 | assert ( 104 | str(wrong_value_type_test.value) 105 | == 'Value for Filetote config key "[test]" should be of type (inner' 106 | " element of the list) , got ``" 107 | ) 108 | 109 | def test__validate_types_str_seq(self) -> None: 110 | """Ensure the str_seq correctly checks for the types.""" 111 | # Test the positive outcome of a `list[str]` 112 | try: 113 | filetote_dataclasses._validate_types_str_seq(["test"], ["string"], '""') 114 | except TypeError as e: 115 | self.fail(f"Exception {type(e)} was raised unexpectedly: {e}") 116 | 117 | # Fail if the value isn't a List 118 | with pytest.raises(TypeError) as non_list_test: 119 | filetote_dataclasses._validate_types_str_seq(["test"], dict, '""') 120 | 121 | assert ( 122 | str(non_list_test.value) 123 | == 'Value for Filetote config key "[test]" should be of type literal `""`,' 124 | " an empty list, or sequence/list of strings (type `list[str]`), got" 125 | " ``" 126 | ) 127 | 128 | # Fail the the inner list value isn't a string 129 | with pytest.raises(TypeError) as non_string_item_test: 130 | filetote_dataclasses._validate_types_str_seq(["test"], [123], '""') 131 | 132 | assert ( 133 | str(non_string_item_test.value) 134 | == 'Value for Filetote config key "[test]" should be of type' 135 | " sequence/list of strings (type `list[str]`), got ``" 136 | ) 137 | 138 | def test__raise_type_validation_error(self) -> None: 139 | """Tests that the formatting for the TypeErrors matches depending on whether 140 | just a value is specified or also dict keys. 141 | """ 142 | with pytest.raises(TypeError) as value_test: 143 | filetote_dataclasses._raise_type_validation_error( 144 | ["test"], dict, value="value" 145 | ) 146 | 147 | assert ( 148 | str(value_test.value) 149 | == 'Value for Filetote config key "[test]" should be of type , got ``" 151 | ) 152 | 153 | with pytest.raises(TypeError) as key_test: 154 | filetote_dataclasses._raise_type_validation_error( 155 | ["test"], "", "dict-value", 12345 156 | ) 157 | 158 | assert ( 159 | str(key_test.value) 160 | == 'Key "12345" for Filetote config key "[test]" should be of type , got ``" 162 | ) 163 | 164 | with pytest.raises(TypeError) as keys_value_test: 165 | filetote_dataclasses._raise_type_validation_error( 166 | ["test"], "", [], 12345, True 167 | ) 168 | 169 | assert ( 170 | str(keys_value_test.value) 171 | == 'Key "12345"\'s Value for Filetote config key "[test]" should be of type' 172 | " , got ``" 173 | ) 174 | 175 | def test__format_config_hierarchy(self) -> None: 176 | """Tests that the output matches the format `[level1][level2[level3]`, etc.""" 177 | single = filetote_dataclasses._format_config_hierarchy(["config"]) 178 | assert single == "[config]" 179 | 180 | multiple = filetote_dataclasses._format_config_hierarchy([ 181 | "top", 182 | "middle", 183 | "end", 184 | ]) 185 | assert multiple == "[top][middle][end]" 186 | -------------------------------------------------------------------------------- /typehints/beets/__init__.pyi: -------------------------------------------------------------------------------- 1 | from confuse import LazyConfig 2 | 3 | class IncludeLazyConfig(LazyConfig): 4 | def read(self, user: bool = True, defaults: bool = True) -> None: ... 5 | 6 | config = IncludeLazyConfig("beets", __name__) 7 | -------------------------------------------------------------------------------- /typehints/beets/dbcore/__init__.py: -------------------------------------------------------------------------------- 1 | class Database: # noqa: D101, D104 2 | def _close(self) -> None: ... 3 | -------------------------------------------------------------------------------- /typehints/beets/dbcore/db.pyi: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterator, Mapping 2 | from typing import Any, Literal 3 | 4 | ALL_KEYS: Literal["*"] = "*" 5 | 6 | class Model: 7 | def keys(self, computed: bool = False) -> Literal["*"] | list[str]: ... 8 | def _get(self, key: str, default: Any = None, raise_: bool = False) -> Any: ... 9 | 10 | get = _get 11 | 12 | def _setitem(self, key: str, value: str) -> bool: ... 13 | def __setitem__(self, key: str, value: str) -> None: ... 14 | def _type(self, key: str) -> str: ... 15 | def formatted( 16 | self, 17 | included_keys: Literal["*"] | list[str] = "*", 18 | for_path: bool = False, 19 | ) -> FormattedMapping: ... 20 | 21 | class FormattedMapping(Mapping[str, str]): 22 | model: Model 23 | for_path: bool 24 | def __init__( 25 | self, 26 | model: Model, 27 | included_keys: Literal["*"] | list[str] = "*", 28 | for_path: bool = False, 29 | ): ... 30 | def __getitem__(self, key: str) -> str: ... 31 | def __iter__(self) -> Iterator[str]: ... 32 | def __len__(self) -> int: ... 33 | -------------------------------------------------------------------------------- /typehints/beets/dbcore/types.pyi: -------------------------------------------------------------------------------- 1 | STRING: str 2 | -------------------------------------------------------------------------------- /typehints/beets/importer.pyi: -------------------------------------------------------------------------------- 1 | from logging import Logger 2 | 3 | from beets.library import Item, Library 4 | 5 | class ImportSession: 6 | lib: Library 7 | logger: Logger | None 8 | paths: list[bytes] 9 | query: str | None 10 | def __init__( 11 | self, 12 | lib: Library, 13 | loghandler: Logger | None, 14 | paths: list[bytes], 15 | query: str | None, 16 | ): ... 17 | def run(self) -> None: ... 18 | 19 | class ImportTask: 20 | def imported_items(self) -> list[Item]: ... 21 | -------------------------------------------------------------------------------- /typehints/beets/library.pyi: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from typing import Any, Pattern 3 | 4 | from .dbcore import Database 5 | from .dbcore.db import Model 6 | 7 | class DefaultTemplateFunctions: 8 | def functions(self) -> dict[str, Callable[..., Any]]: ... 9 | 10 | class Library(Database): 11 | path: bytes 12 | directory: bytes 13 | path_formats: list[tuple[str, str]] 14 | replacements: list[tuple[Pattern[str], str]] | None 15 | def __init__( 16 | self, 17 | path: bytes, 18 | directory: str = "~/Music", 19 | path_formats: list[tuple[str, str]] = [], 20 | replacements: list[str] | None = None, 21 | ): ... 22 | 23 | class LibModel(Model): ... 24 | 25 | class Item(LibModel): 26 | id: int 27 | path: bytes 28 | 29 | def __init__(self) -> None: ... 30 | -------------------------------------------------------------------------------- /typehints/beets/logging.pyi: -------------------------------------------------------------------------------- 1 | from logging import Logger 2 | 3 | def getLogger(name: str | None = None) -> Logger: ... 4 | 5 | DEBUG: int 6 | -------------------------------------------------------------------------------- /typehints/beets/plugins.pyi: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from logging import Logger 3 | from typing import Any 4 | 5 | from beets import config 6 | 7 | class BeetsPlugin: 8 | name: str 9 | config = config 10 | _log: Logger 11 | def __init__(self, name: str | None = None): ... 12 | def register_listener(self, event: str, func: Callable[..., Any]) -> None: ... 13 | 14 | def send(event: str, **arguments: Any) -> list[Any]: ... 15 | def find_plugins() -> list[Any]: ... 16 | def load_plugins(names: list[str]) -> None: ... 17 | 18 | _instances: dict[Any, Any] 19 | 20 | _classes: set[Any] 21 | -------------------------------------------------------------------------------- /typehints/beets/ui/__init__.pyi: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from beets.util.functemplate import Template 4 | 5 | def get_path_formats(subview: Any | None = None) -> list[tuple[str, Template]]: ... 6 | -------------------------------------------------------------------------------- /typehints/beets/ui/commands.pyi: -------------------------------------------------------------------------------- 1 | from beets.library import Library 2 | 3 | def move_items( 4 | lib: Library, 5 | dest: bytes | None, 6 | query: str, 7 | copy: bool, 8 | album: str | None, 9 | pretend: bool, 10 | confirm: bool = False, 11 | export: bool = False, 12 | ) -> None: ... 13 | def modify_items( 14 | lib: Library, 15 | mods: dict[str, str], 16 | dels: dict[str, str], 17 | query: str, 18 | write: bool = True, 19 | move: bool = True, 20 | album: str | None = None, 21 | confirm: bool = False, 22 | inherit: bool = True, 23 | ) -> None: ... 24 | def update_items( 25 | lib: Library, 26 | query: str, 27 | album: str | None = None, 28 | move: bool = True, 29 | pretend: bool = True, 30 | fields: list[str] | None = None, 31 | exclude_fields: list[str] | None = None, 32 | ) -> None: ... 33 | -------------------------------------------------------------------------------- /typehints/beets/util/__init__.pyi: -------------------------------------------------------------------------------- 1 | from collections.abc import Generator, Sequence 2 | from enum import Enum 3 | from logging import Logger 4 | from re import Pattern 5 | from typing import Any, AnyStr 6 | 7 | from typing_extensions import TypeAlias 8 | 9 | Bytes_or_String: TypeAlias = str | bytes 10 | 11 | def ancestry(path: bytes) -> list[str]: ... 12 | def displayable_path( 13 | path: Bytes_or_String | tuple[Bytes_or_String, ...], 14 | separator: str = "; ", 15 | ) -> str: ... 16 | def normpath(path: bytes) -> bytes: ... 17 | def sanitize_path( 18 | path: str, 19 | replacements: Sequence[Sequence[Pattern[Any] | str]] | None = None, 20 | ) -> str: ... 21 | def unique_path(path: bytes) -> bytes: ... 22 | def bytestring_path(path: Bytes_or_String) -> bytes: ... 23 | def mkdirall(path: bytes) -> None: ... 24 | def syspath(path: bytes, prefix: bool = True) -> Bytes_or_String: ... 25 | def copy(path: bytes, dest: bytes, replace: bool = False) -> None: ... 26 | def move(path: bytes, dest: bytes, replace: bool = False) -> None: ... 27 | def link(path: bytes, dest: bytes, replace: bool = False) -> None: ... 28 | def reflink( 29 | path: bytes, 30 | dest: bytes, 31 | replace: bool = False, 32 | fallback: bool = False, 33 | ) -> None: ... 34 | def hardlink(path: bytes, dest: bytes, replace: bool = False) -> None: ... 35 | def prune_dirs( 36 | path: bytes, 37 | root: Bytes_or_String | None = None, 38 | clutter: Sequence[str] = (".DS_Store", "Thumbs.db"), 39 | ) -> None: ... 40 | def sorted_walk( 41 | path: AnyStr, 42 | ignore: Sequence[Bytes_or_String] | None = (), 43 | ignore_hidden: bool = False, 44 | logger: Logger | None = None, 45 | ) -> Generator[tuple[bytes, list[bytes], list[bytes]], None, None]: ... 46 | 47 | class MoveOperation(Enum): 48 | MOVE = 0 49 | COPY = 1 50 | LINK = 2 51 | HARDLINK = 3 52 | REFLINK = 4 53 | REFLINK_AUTO = 5 54 | -------------------------------------------------------------------------------- /typehints/beets/util/functemplate.pyi: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from typing import Any 3 | 4 | from ..dbcore.db import FormattedMapping 5 | 6 | class Template: 7 | expr: list[Any] 8 | original: str 9 | def __init__(self, template: str) -> None: ... 10 | def substitute( 11 | self, 12 | values: FormattedMapping, 13 | functions: dict[str, Callable[..., str]] = {}, 14 | ) -> str: ... 15 | -------------------------------------------------------------------------------- /typehints/confuse/__init__.pyi: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | class ConfigView: 4 | def __getitem__(self, key: str) -> Any: ... 5 | def __setitem__(self, key: str, value: Any) -> Any: ... 6 | 7 | class RootView(ConfigView): 8 | sources: list[Any] 9 | def __init__(self, sources: list[Any]): ... 10 | 11 | class Configuration(RootView): 12 | def __init__( 13 | self, 14 | appname: str, 15 | modname: str | None = None, 16 | read: bool = True, 17 | ): ... 18 | 19 | class LazyConfig(Configuration): 20 | def __init__(self, appname: str, modname: str | None = None): ... 21 | def add(self, value: dict[str, Any]) -> None: ... 22 | def clear(self) -> None: ... 23 | -------------------------------------------------------------------------------- /typehints/confuse/templates.pyi: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | class Template: 4 | def __init__(self, default: object = ...): ... 5 | 6 | class OneOf(Template): 7 | def __init__(self, allowed: list[Any], default: object = ...): ... 8 | 9 | class StrSeq(Template): 10 | def __init__(self, split: bool = ..., default: object = ...): ... 11 | -------------------------------------------------------------------------------- /typehints/mediafile/__init__.pyi: -------------------------------------------------------------------------------- 1 | from typing_extensions import TypeAlias 2 | 3 | Bytes_or_String: TypeAlias = str | bytes 4 | 5 | class MediaFile: 6 | def __init__(self, filething: Bytes_or_String, id3v23: bool = False): ... 7 | def save(self, **kwargs: dict[str, object]) -> None: ... 8 | 9 | TYPES: dict[str, str] 10 | -------------------------------------------------------------------------------- /typehints/reflink/__init__.pyi: -------------------------------------------------------------------------------- 1 | from typing_extensions import TypeAlias 2 | 3 | Bytes_or_String: TypeAlias = int | str 4 | 5 | def supported_at(path: Bytes_or_String) -> bool: ... 6 | --------------------------------------------------------------------------------