Developing¶
Prerequisites¶
- uv (manages the Python installation and project dependencies)
- Node.js 22.18+ (lint tool dev dependencies)
make4.3+ (the rendered-icon targets use grouped targets,&:)shellcheckon PATH (non-Linux only; on Linux,make lintdownloads a pinned release into.cache/shellcheck/)actionlinton PATH (non-Linux only; on Linux,make lintdownloads a pinned release into.cache/actionlint/)
Overview¶
Run make to install dependencies + static checks (lint, format, types,
spelling) + test. If no suitable Python (3.12+) is installed, uv will download
and use one automatically.
The dependency lockfile (uv.lock) is committed, and make install fails if it
is out of date with pyproject.toml. After changing dependencies, run uv lock
(or uv add/uv remove) and commit the updated lockfile.
The lint tools are Node.js dev dependencies defined in package.json with
versions pinned in package-lock.json. make install-js installs them into
node_modules/; make static and make fix run it automatically, and it fails
if the lockfile is out of date with package.json.
Run¶
Run the app with uv run albums [...]. The first time you do, you may run
uv run albums --db-file albums.db init in the project directory, which will
create a "local" albums.db there for a test environment (separate from the db
used by a regular installation of albums).
Project Files and Folders¶
| Path | Description |
|---|---|
.github/workflows |
Github workflows (build/publish/docs) |
docs/ |
This documentation |
src/albums/ |
Python application (structure below) |
scripts/ |
Development scripts (e.g. version.py) |
tests/ |
Tests! |
Makefile |
The Makefile |
hooks/ |
Git hooks (commit-msg, pre-commit, pre-push) |
package.json |
Node.js dev dependencies (lint tools) |
pyproject.toml |
Project definition, tool configuration, dependencies |
zensical.toml |
Configuration for this documentation |
(not all files/folders included)
Versioning and Packaging¶
The package version is derived from git tags at build/install time, using
hatch-vcs (a wrapper
around setuptools-scm). A commit that
is tagged vX.Y.Z builds as X.Y.Z, and any later commit builds as
X.Y.Z.postN+<commit> (plus a date suffix if the working tree is dirty), so
every commit has a distinct version.
During install or build, the computed version is written to
src/albums/_version.py (gitignored), which the app reads for
albums --version. scripts/version.py prints the same version for the current
checkout, and write writes the _version.py file:
make packagebuilds the sdist and wheel indist/(used to publish to PyPI)make pyinstallerwrites the version, then builds a standalone executable indist/pyinstaller/<platform>/albums/with PyInstallermake docsrenders the docs images, including the project icon fromdocs/art/icon.png, and injects the version into the built docs site- Windows builds (release CI, local Windows, and Linux with wine) are documented in Windows Builds
Python Project Structure¶
| Package | Description |
|---|---|
app |
App context (db, config, console) shared across functions |
config |
Configuration types, defaults, serialization |
entities |
ORM models (Album, Track, PictureFile, OtherFile) |
checks |
Check/fixer implementations and orchestration |
cli |
Entry point and command implementations |
database |
DB creation, migrations, queries |
interactive |
UI for interacting with checks \u0026 configuration |
library |
Scan library, import album, sync to destination |
picture |
Get picture info, caching picture scanner |
tagger |
Read/write metadata in media files |
words |
Simple text generation e.g. pluralize words |
Key Types to Know¶
Context(app.py) - Carries shared state (db engine, config, console) across invocation.Album/Track/PictureFile/OtherFile(entities.py) - SQLAlchemy ORM models representing the data model.CheckConfiguration(checks/check_types.py) - Per-check config dict type.Fixer/CheckResult(checks/check_types.py) - Problem reporting and fix contracts (see Fixers).
Adding Functionality¶
Checks¶
Checks are grouped into categories under src/albums/checks/:
fields/- Metadata field presence and consistency checksnumbering/- Track/disc numbering validationpath/- Filename and path structure checkspicture/- Album art embedding, dimensions, duplicates
Each check file defines one class. Extend base_check.Check for general checks
or base_check_field_per_album for field-consistency checks.
To add a new check:
- Create a class in the appropriate
checks/<category>/subdirectory - Define
name,default_config(dict with"enabled": Trueplus any custom options) - Implement
check(album: Album) -> CheckResult | None - Add to
ALL_CHECKStuple inchecks/all.py - Optionally define
must_pass_checksto depend on earlier checks
The check() method gets an ORM Album with loaded tracks. Check and fix via
self.session, self.tagger, and self.ctx. Return None if passed, or a
CheckResult with a message and optional Fixer.
Check guidelines: fast and stateless¶
Two rules keep checks repeatable and fast (a million checks should run in a few seconds):
check()should rely on data in the database. It should not read tags from files, or perform file system or network operations, or anything "slow". Tag data (fields, pictures, stream info) is loaded during the scan and available on the ORM entities, and additional queries viaself.sessionare fine. Any exception must be defensible and commented, e.g.folder-namedoes a singleexists()stat - and only for an album that has already failed the check - because a rename collision depends on disk state that is not in the database.- Checks should be stateless, holding only configuration. Instance
attributes should be set in
init()from the check configuration (or be the injectedctx/tagger/session), andcheck()must not set any state on the Check object. The one exception isduplicate-album, which holds an in-memory index of the library built in__init__to compare albums across the whole library; see its docstring for the justification.
Related guidance:
- Slow work belongs in the fix phase: the fixer's
fix(option)callback may read/write files, rename, or download (e.g.cover-availableruns an external download command). - Table row data for interactive display can be deferred by passing a row
factory callable instead of rows (
Fixer.get_table()resolves it only when the table is actually displayed). This keeps slow work such as image decoding (picture checks) or reading the album folder for proposed filenames (unreadable-track) out ofcheck().
Fixers¶
The Fixer object returned in a CheckResult has a list of option strings, and
specifies whether a "free text" option should be displayed. It includes a
fix(option) function to call once a decision is made. If an automatic fix is
being offered, the fixer sets option_automatic_index to point to the option
that is the automatic selection.
The fixer may also optionally define a table (headers and row data) that should be displayed to the user in interactive modes to help them decide which option to pick. Generating row data can be deferred until display so the check can be fast if that is slow.
The fix(option) callback does the work and returns a FixResult. The
Checker owns the database transaction: after an applied fix that changed
anything, it re-scans the album from disk (rebuilding the album's database rows
from the files) and re-runs the checks until the album passes. The fixer
contract follows from that:
- Persist all file changes to disk. The re-scan re-reads every file, so
tagging, renaming, moving and deleting must be real file changes; edits made
only to ORM entities (e.g. editing
track.fields) are reverted. - Mutate ORM entities directly only for metadata not stored in the files,
which the re-scan leaves alone (e.g.
PictureFile.cover_source,Album.collections,Album.ignore_checks); updateAlbum.pathwhen renaming the album folder on disk. - Return the matching
FixResult.NO_CHANGEskips the re-scan and commit;CHANGED_ALBUM/CHANGED_OTHERtrigger the re-scan and a commit;DELETED_ALBUMremoves the album's rows, which are committed at the end of the run (a crash in between is harmless: the next run removes the stale rows). - Never call
session.commit(). Committing from a fixer would break the re-run-after-fix behavior and could persist a partially fixed album.
Tips:
- The
check()method should avoid slow operations - see the check guidelines above. - If returning one result is limiting, maybe the check should be two checks.
- Consider checking for "pass" conditions first in some cases.
Writing Tests¶
Tests live in tests/, mirroring src/albums/. Use pytest with class-based
tests. Construct Albums and Tracks directly; use Context() for app state.
Example:
from albums.app import Context
from albums.entities import Album, Track
from albums.tagger import AlbumTagger
from albums.tagger import BasicField
class TestMyCheck:
def test_missing_field(self, mocker):
album = Album(
path="foo/",
tracks=[
Track(filename="1.flac", tag={BasicField.ALBUM: "Foo"}),
Track(filename="2.flac"), # missing album field
],
)
result = MyCheck(Context()).check(album)
assert result.fixer
mock_set_basic_fields = mocker.patch.object(AlbumTagger, "set_basic_fields")
result.fixer.fix(result.fixer.options[0])
assert mock_set_basic_fields.call_count == 1
Library fixture data is generated in tests/tmp/. Run make test for full
suite, or uv run pytest tests/path/to/test.py -v for targeted runs.
Music File Tag Support¶
An AlbumTaggerProvider instance provides configured AlbumTagger instances.
AlbumTagger.open() selects a FileTagger implementation class based on the
file extension.
Support for different file types is provided by FileTagger implementations in
albums.tagger.file_types. The mapping from file extensions to tagger
capabilities and implementation classes is in albums.tagger.folder.
FileTagger implementations for music files all extend AbstractMutagenTagger.
File types that use ID3 extend AbstractId3Tagger.
Common Tags¶
The tagger only uses values in BasicField. All
files with basic fields must read/write each one. Add a tag field by:
- Add to the
BasicFieldenum with comments showing ID3/vorbis/M4A equivalents - For FLAC/Ogg Vorbis, use same name or edit functions in
tagger/vorbis.py - For MP3/AIFF: add to
tagger/base_id3.pyAbstractId3Tagger - For other types: implement in
tagger/file_types/ - Add a test case in the appropriate
tests/checks/fields/test file
Commit style¶
This project uses Conventional Commits. The subject line of the commit message must be <= 50 characters. The commit body should be omitted for small changes.
Git hooks¶
The commit-msg, pre-commit and pre-push hooks in hooks/ gate
commits and pushes on commit message style, a clean working tree plus the
relevant checks. The hooks are enabled when any lint operation runs and Node.js
dependencies are installed. (The project can be built and tested without these.)
| Hook | Requires |
|---|---|
commit-msg |
Conventional Commits message, subject <= 50 chars (commitlint) |
pre-commit |
clean working tree + the checks the commit can affect pass |
pre-push |
clean working tree + make test passes (no-op pushes skip) |
commit-msg checks the final commit message with
commitlint (@commitlint/cli), configured in
commitlint.config.js (extends @commitlint/config-conventional, 50 character
subject limit). Standard git-generated messages (e.g. Merge branch ... and
Revert ...) are accepted by commitlint itself.
pre-commit runs only the checks the commit can affect, chosen by the staged
files' types: ruff (fix) for *.py, rumdl (fix) for *.md, shellcheck for
*.sh and hooks/, actionlint for .github workflow yml, jsonc for
*.json/*.jsonc/*.json5, node for *.js, cspell for everything, plus
pyright on src/ and tests/ if changed.
If the auto-fixing checks change files during pre-commit, the changes are
also staged.
pre-push skips its checks when the push updates nothing (all refs already up
to date) or only deletes refs, since no new commits are published.
Tips¶
Lint, format and static analysis¶
No warnings, only pass/fail. make static runs all static checks. Each tool
also has its own target for targeted runs: make lint-python (ruff),
make lint-markdown (rumdl), make shellcheck, make actionlint,
make jsonc, make js, make typecheck (pyright), make spelling. Some
lint/format problems can be automatically fixed with make fix-python and
make fix-markdown (or make fix for both).
- lint/format with ruff (format same as Black) - pycodestyle/pyflakes error rules (E4, E7, E9, F) plus isort (I), 150 character line limit, on Python files only (markdown is linted with rumdl)
- shell lint with shellcheck - all shell
scripts in the project (the git hooks in
hooks/plus any*.shfile), run bymake lintviascripts/shellcheck.py, which on Linux downloads a pinned release (v0.11.0) into.cache/shellcheck/and on other platforms requiresshellcheckon PATH - workflow lint with actionlint -
GitHub Actions workflows, including shellcheck of their
run:shell blocks, run bymake lintviascripts/actionlint.py, which on Linux downloads a pinned release (v1.7.9) into.cache/actionlint/and on other platforms requiresactionlinton PATH - static type checking with pyright - strict mode for main project, looser rules for tests.
- markdown lint + reflow with rumdl - wraps prose at 80
columns, run by
make fix(uses.rumdl.tomlconfig) - config file syntax with node -
node --checkforcommitlint.config.jsand jsonc-parser for the JSONC (comments allowed) files in.vscode/
Not linted, validated elsewhere instead: scripts/albums.iss is compiled by
Inno Setup (iscc) in the release builds, and the SQL migrations are
exercised by the migration tests. The JS files (commitlint.config.js,
scripts/check_jsonc.js) are deliberately not linted beyond the
node --check syntax check, since the project's JS is minimal config and the
linter itself.
Spell check¶
Builds require cSpell spell check to pass.
make spelling runs cSpell via npx. Add
valid words and relevant technical terms to cspell.json.
IDE¶
Use an IDE like Visual Studio Code that supports ruff/Black formatting and a pyright language server and cSpell. rumdl can reflow Markdown text.
Database Schema¶
Database migrations are SQL files in
database/migrations/. Scanner version
(SCANNER_VERSION in library/rescan.py)
tracks changes. To refresh the ER diagram run:
This generates the sample database, then renders it with
eralchemy, which invokes the Graphviz dot
executable. So in addition to the project dependencies, the
GraphViz binaries must be installed (e.g.
sudo apt install graphviz on Debian/Ubuntu, brew install graphviz on macOS).

Querying the Database¶
Use albums sql "SELECT * FROM album LIMIT 10;" or albums list --json to
inspect library data.
Previewing Docs¶
make preview builds the documentation assets first (including the database
diagram, which requires Graphviz - see Database Schema
above) and then serves the site with zensical.