Metadata-Version: 2.4
Name: libavalon
Version: 0.0.6
Summary: Standalone audio analysis, tagging, and organization CLI
Author: abelsonlive
License: MIT
License-File: LICENSE
Requires-Python: <3.12,>=3.10
Requires-Dist: essentia-tensorflow==2.1b6.dev1110
Requires-Dist: ffmpeg-python>=0.2.0
Requires-Dist: mutagen>=1.48.0
Requires-Dist: numpy<2
Requires-Dist: requests>=2.31.0
Requires-Dist: watchdog>=6.0.0
Provides-Extra: test
Requires-Dist: pytest-cov>=4.0.0; extra == 'test'
Requires-Dist: pytest>=8.0.0; extra == 'test'
Description-Content-Type: text/markdown

# avalon

Analyzes, tags, and organizes a music library:
- BPM/key extraction, mood/genre/energy descriptors via Essentia
- ID3/Vorbis/MP4 tag normalization
- cover art, format conversion

Runs once over a folder or as a watching daemon. MusicBrainz/Discogs
reconciliation is left to Picard.

## Requirements

- Python 3.10–3.11 (see the `essentia-tensorflow` pin in `pyproject.toml` for why)
- [uv](https://docs.astral.sh/uv/)
- `ffmpeg` on `PATH` — `brew install ffmpeg` / `apt install ffmpeg`

## Install

```bash
git clone <repository-url> && cd avalon
uv sync
```

OR 

```shell
pip install libavalon
```

First run downloads Essentia's models (~26.5MB) to `~/.cache/avalon/models/`.

## Usage

```bash
# tag in place
uv run avalon analyze ~/Music/Downloads --recursive

# reorganize into {artist}/{album}/{title}.{ext}
uv run avalon analyze ~/Music/Downloads --recursive --dest ~/Music/Library

# convert lossless sources, cap bit depth/sample rate (lossy sources untouched)
uv run avalon analyze ~/Music/Downloads --dest ~/Music/Library \
    --convert-lossless-to aiff --max-bit-depth 16 --max-sample-rate 48000

# watch continuously, -v so you can see it working (scans on startup, then
# re-scans every --rescan-seconds to catch anything the OS didn't report)
uv run avalon watch ~/Music/Downloads --dest ~/Music/Library -v

# backfill a large library faster with 8 concurrent worker processes
uv run avalon analyze ~/Music/Downloads --recursive --dest ~/Music/Library --workers 8

# see what's actually in a file's tags
uv run avalon inspect ~/Music/Library/Artist/Album/01\ -\ Title.aiff
```

Full flag list: `avalon analyze --help` / `avalon watch --help`.

## How it works

```mermaid
flowchart TD
    src[source file]
    src --> analyze[essentia analysis]
    src --> conv{convert?}
    conv -->|yes| ffmpeg
    conv -->|no| copy[copy in place]
    analyze --> write[write tags + art]
    ffmpeg --> write
    copy --> write
    write --> out[output file]
```

Analysis runs against the original file, before any conversion. Canonical
fields (title/artist/album/genre/bpm/key) only fill in when missing —
nothing gets overwritten unless you pass `--force-reanalyze`.

`--workers N` runs analysis in N separate worker processes instead of one
at a time — each has its own Essentia/TensorFlow session, so results never
cross between files. Destination-path collisions (e.g. two files with
missing tags both falling back to the same `Unknown Artist/Unknown Album`
path) are still resolved from a single process before any work is handed
to a worker, so numbering stays correct under `--workers` too.

### Watch mode

`watch` notices files two ways, and needs both. Filesystem events give it
low latency; a full rescan every `--rescan-seconds` (default 300) gives it
correctness. The rescan is not redundant — a recursive watch is really one
watch descriptor per subdirectory, added only after the observer sees the
parent appear, so a folder created and filled faster than that (dragging an
album in, an rsync, an unzip) can have its contents land before anything is
watching them. Those files produce no event at all, and event-driven-only
watching strands them silently and forever. Set `--rescan-seconds 0` to turn
the sweep off.

Both commands keep a `.avalon_state.json` fingerprint index so re-runs skip
files that haven't changed. It lives in `--dest` for `analyze` and in the
first watched folder for `watch` — the two track different key sets (source
paths under the watched folder vs. under the library), and each save
rewrites the whole file from that process's in-memory copy, so two runs
sharing one state file will erase each other's entries. Use `--state-dir` if
you need to place it explicitly.

## Tags

Two avalon-owned tags per file: a short headline (`bpm:128;key:Am;camelot:8A;
energy:0.71;genre:Techno`, in COMM/DESCRIPTION/desc, configurable via
`--headline-tag`/`--headline-format`) and an extended tag with the full
descriptor roster (`TXXX:AVALON_ANALYSIS` / a Vorbis field / an MP4 atom).

MusicBrainz/Discogs/AcoustID reconciliation isn't handled by avalon — run
[Picard](https://picard.musicbrainz.org/) over the library separately for that.

## Development

```bash
uv sync --extra test
uv run pytest
```
