Behavior change (no package change): the default is now a pure strip. apestrip deletes the APEv2 block and leaves ID3 byte for byte; it no longer absorbs APE fields into ID3 unless you ask.
- Why. Migrating APE values into ID3 by default was backwards. The whole reason to run apestrip is to drop stray APE values (the genre above all), so copying them into ID3 is exactly how a bad APE genre ends up baked into the real tags. The old default forced manual genre cleanup afterward.
--keep-metadatarestores the old behavior. With the flag, every APE field whose value is not already in ID3 is migrated into the correct frame before the strip, exactly as before (genre still never migrated, ratings still report-only). Without it, nothing is migrated.- Always reported. APE
GenreandRatingare scanned and reported in both modes, so the worklist still shows what is being dropped (and still warns when stripping an APE genre would leave a file with no genre at all). - Repair too.
--repair-malformedfollows the same rule: the malformed block is always excised, but sole-source fields are migrated into ID3 only when--keep-metadatais also given. - Tests extended for both modes (
tests/test_apestrip.py).
New companion script (no package change): a lossless stripper for stray APEv2 tags on MP3s.
- The problem. Some MP3s (commonly torrent rips) carry a hidden APEv2 tag alongside their ID3 tags. Players that read APEv2 on MP3 (foobar2000, DeaDBeeF) merge the APE values over the ID3 ones, so a stray APE genre such as
Trash Metalkeeps showing up asTrash Metal, Metalno matter how often the ID3 genre is corrected, and ordinary tag editors never touch the APEv2 block.retag.pyalready deletes APEv2 as a side effect of rewriting the genre, but only the genre;apestrip.pyis the general fix. - Lossless by design. Before deleting the APEv2 tag, every APE field whose value is not already in ID3 is migrated into the correct frame: core text to its native frame (
Year→TDRC,Title/Artist/Album→TIT2/TPE1/TALB,Album Artist→TPE2, etc.),Comment→COMM,Cover Art (Front)→APIC,Unsynced lyrics→USLT, sort orders toTSO*, and anything else (MusicBrainz IDs, ISRC, barcode, ReplayGain, ...) to aTXXX:<key>passthrough. - Two deliberate exceptions. Genre is never migrated (ID3 stays authoritative; the APE genre is the value being removed; a file with no ID3 genre is reported, not invented). Rating is never written, because APE and
POPMuse different scales and an auto-conversion would corrupt star counts (the hazardrerate.pyexists for); APE ratings are reported instead. - Guarded like the other companions.
--dry-runpreviews the full worklist and writes nothing; the real run prints the worklist and asks for confirmation (--yesto bypass, auto-bypassed when stdin is not a TTY); an append-only timestamped log is written (default<directory>/apestrip.log); the operation is idempotent. Recursive over the given directory, so it handles one album or a whole library. MP3-only (the APEv2-over-ID3 conflict is specific to MP3). Migrations are saved as ID3v2.3 + refreshed ID3v1, matchingretag.py. Tested bytests/test_apestrip.py. - Malformed-tag handling (
--repair-malformed). Some rips carry a structurally broken APEv2 tag (footer with theIS_HEADERbit wrongly set, junk bytes before a trailing ID3v1) that mutagen refuses to load. By default these are reported, not silently skipped, so the run is honest about what it could not touch.--repair-malformedopts into fixing them: the tag is parsed straight from the bytes, but only after proving the footer sits exactly where the header's size field points (so the excision boundary is a real tag edge, not a chance signature in the audio); sole-source fields are migrated first, then the APE block is cut out via direct byte surgery, written to a temp file, verified (still decodes, no APE signature left), and atomically swapped in. Audio frames and the trailing ID3v1 are preserved byte for byte; any failed check leaves the original untouched.
Bugfix release from a full code review; no new features.
- Fix: the no-curses fallback menu dispatched the wrong modes. The typed-input menu shown when curses is unavailable (or stdin is not a TTY) had a hand-maintained key map that drifted as modes were added: "6" was labelled "Test WAV files" but ran Extract cover art (which writes
cover.jpgfiles if the dry-run prompt is declined), "7"–"9" were each shifted one mode group over, "10"–"13" were rejected as invalid, and the WAV/WMA tests, art-quality audit, bitrate audit, and ReplayGain audit were unreachable by number. The fallback listing and key map are now both generated from the same_MAIN_SECTIONS/_LIB_SECTIONSdata the arrow-key menu renders, so they cannot drift again, and new word aliases (wav,wma,quality,bitrate,rg/replaygain) cover the previously unreachable modes. The curses menu itself was never affected. Pinned by the newtests/test_tui.py. - Fix: smart-playlist AND/OR no longer rewrites string literals. The SQL-style convenience was a plain substring replace, so
genre == 'Drum AND Bass'was silently rewritten to compare against'Drum and Bass'and could never match. The fold is now word-bounded and applied only outside quoted segments; as a side effect,(rating >= 4)AND(...)without padding spaces now also works. - Fix: art-quality audit no longer truncates folder covers. Folder art was read 8 KB at a time "for the header", but a large EXIF/ICC block can push the JPEG size marker past any fixed prefix; such covers parsed as unreadable and were silently skipped instead of flagged. The whole file is read now (embedded art always was).
- Fix: FLAC report header now includes the Metadata tier count, matching the other integrity reports, so the per-tier counts always sum to Scanned (previously METADATA-tier files, mostly from the ffmpeg fallback path, were invisible in the report).
- Hardening/cleanup:
_parse_track_numbertolerates a malformed MP4trknatom containingNone(previously an uncaughtTypeErroraborted the rest of that file's tag parse); the rule evaluator's comparison walk useszip(strict=True); an unreachable duplicatedreturninrun_replaygain_auditwas removed;.gitignorenow covers the default report outputs so runs from the repo root stay out ofgit status. - Known limitation (deferred): the curses prompt accepts ASCII-only typed input, so a non-ASCII path or output name cannot be typed into the TUI (the CLI and the prompt defaults are unaffected).
Companion-script fix and hardening (no package change):
- Wrong-root guard.
classifycollapsed any directory to its last two path components regardless of depth, and its docstring's promise to "flag" deeper directories was never implemented. Pointing the tool at the parent of an already-organizedGenre/Artist/Albumlibrary (e.g./mnt/SharedDatainstead of/mnt/SharedData/Music) therefore read every album one level too high, discarded the genre level, and planned to move and prune the entire tree. A directory deeper thanGenre/Artist/Albumis now flaggedTOO DEEPand skipped. On a real ~1810-album library the parent-root dry-run went from 1820 planned moves plus 930 prunes to zero moves and a wall ofTOO DEEPflags. - Placement gated by the library's existing genres. The tool now learns the genre vocabulary from the folders that already hold a
Genre/Artist/Albumtree and only files a stray into one of those. A stray whose dominant tag genre isn't already in use is flaggedUNKNOWN GENREand skipped instead of spawning a new top-level folder from a typo or junk tag;--allow-new-genrelifts the gate. A flat library with no genre folders yet has an empty vocabulary, so the gate is off and the originalArtist/Album→Genre/Artist/Albumconversion is unchanged. The same gate is what makes a wrong-root run inert: nothing matches the (absent) vocabulary. - Organized albums are never silently re-filed. An album already at
Genre/Artist/Albumwhose folder genre disagrees with its tags is reported as aNOTEand left in place, rather than being moved across genre folders without warning. - Tested by
tests/test_genre_foldermap.py(depth classification, gating known/unknown/greenfield/--allow-new-genre, in-place skip and mismatch NOTE, the too-deep flag).
Companion-script hardening (no package change):
- Cross-platform robustness. The rsgain subprocess output is now decoded with
errors="replace", so a stray undecodable byte (cp1252 on Windows, or a non-UTF-8 Linux locale) is logged rather than crashing the run. The "rsgain not found" message now points to the right installer per OS (dnf/package manager,brew,winget/scoop/choco) instead of Fedora only. The script was already portable Python (paths viaos.path, no shell, UTF-8 logging); these close the two remaining rough edges. Documented under the script's new "Cross-platform" note in the README.
- Feature: ReplayGain audit (
--auditReplayGain). A new read-only mode reports per-album ReplayGain coverage, sorting every album into one of four buckets: MISSING (no track tagged), PARTIAL (some tracks tagged, some bare), NO ALBUM GAIN (every track has track gain but album gain is absent, so album-mode playback has nothing to apply), and OK (fully tagged, summarized and listed only with--verbose). It is format-aware: Opus stores gain asR128_TRACK_GAIN/R128_ALBUM_GAINrather than thereplaygain_*_gaintext tags MP3 and FLAC use, and both are recognized, so an R128-tagged Opus album is not mis-flagged as untagged. Added to the CLI, the TUI (Metadata section),spec.md, and the test suite (format detection and bucket classification as pure-helper tests, plus an end-to-end mode test over a generated FLAC library). Newread_replaygainreader intags.pyand aDEFAULT_REPLAYGAIN_AUDIT_OUTPUTconstant. - Companion script:
replaygain.pyv1.1.0. The writing counterpart to the audit. It wrapsrsgain(libebur128, ReplayGain 2.0, the -18 LUFS / 89 dB reference foobar2000 uses) to scan and write track + album gain/peak tags album-by-album, the way foobar's "Scan selection as album" does. Album = one folder, rescanned as a whole so album gain is correct;--skip-taggedskips a fully-tagged album as a unit (never half-skips, which would corrupt album gain).--dry-runlists every album and its current coverage without invoking rsgain; a real run prints the worklist and asks for confirmation before writing (--yesto skip). After each album the written tags are read back and logged.--target-lufs N(v1.1.0) targets a louder or quieter result than the 89 dB / -18 LUFS standard (e.g. -14 ≈ 93 dB, the streaming-loudness range): it switches rsgain to custom mode and writes standard replaygain_* tags for every format including Opus (the R128 convention is fixed at -23 LUFS and cannot carry a custom target, so it is not used for that path); valid range -30 to -5, default stays the 89 dB standard. Lives inscripts/(outside the package's read-only contract, like the other companions) and importslatticefor the format-aware ReplayGain reader. Requiresrsgainon PATH (not bundled). Tested bytests/test_replaygain.py(pure helpers; the rsgain call is mocked). - Cleanup:
read_tags_concurrentnow delegates to a small generalmap_concurrenthelper inutils.py(the ReplayGain audit reuses it); behavior is unchanged.run_bitrate_auditnow returns0explicitly to match its-> intannotation and the sibling audits (the exit code was already 0).
Hardening pass from a full code audit.
- Security: smart-playlist rules no longer use
eval.--playlistrules were evaluated witheval(rule, {"__builtins__": {}}, ...), which is not a sandbox: a rule likegenre.__class__.__mro__[-1].__subclasses__()escapes it to arbitrary code. Rules are now evaluated by walking a restricted AST (comparisons, boolean and arithmetic operators, the exposed field names, literals); attribute access, calls, and subscripts are refused. Same rule syntax, no behavior change for valid rules. - Fix:
--statsartist count on a genre-first tree. The artist tally was derived from the top-level directory, so on a Genre/Artist/Album layout it counted genres, not artists.run_statsnow takes the pathlayout(like the other modes) and reads the artist from the correct component. Album counts were already layout-independent. - Performance: read-heavy modes read tags concurrently. New
read_tags_concurrenthelper (a small thread pool, since tag reads are I/O-bound) now backs duplicate detection, the tag and bitrate audits, and statistics, matching the integrity scanners' existing concurrency. Duplicate detection also drops a redundant whole-library tag cache, roughly halving its peak memory. - Fix: bitrate audit default output path now uses
DEFAULT_BITRATE_AUDIT_OUTPUTinstead of string-rewriting the tag-audit constant. retag.py(v1.1.0) now writes genres to.wma(ASFWM/Genre); raw ADTS.aacstays unsupported (no tag container) and is documented as such.genre_foldermap.py(v1.1.0): artist-level sidecar files (e.g. anArtist/cover.jpgbeside album subfolders) now follow the artist to its dominant genre instead of being orphaned in an emptied folder; the dry-run now predicts directory pruning instead of reading the unchanged disk.- Cleanups:
rerate.pyshares one read-only scan between preview and apply; lazier POPM rating fallback intags.py;cleaner.py/rerate.pyexception handlers collapsed to a single base class where one subsumed the other.
- Feature: configurable path-extraction layout. The layout Lattice uses to recover artist/album/genre from a file's path (when a tag is missing) is now settable. A new
layoutkey in~/.config/lattice/config.jsonbecomes the default for every scanning mode, and--layoutoverrides it per-run. A genre-first library can pin"{genre}/{artist}/{album}"once instead of passing the flag each time. The default remains{artist}/{album}, so existing setups are unaffected. Newconfig.DEFAULT_LAYOUTconstant andconfig.get_layout()helper back this. - Feature: genre falls back to the path. The library scanner already recovered a missing artist/album from the directory layout; it now does the same for a missing genre (
t.genre or parsed.get("genre")). With a{genre}/...layout, an untagged file is still placed in the right wing. - Companion script:
genre_foldermap.pyv1.0.0. A new destructive tool inscripts/that restructures a flatArtist/Albumlibrary intoGenre/Artist/Album, moving each album folder under its dominant genre (read through Lattice's scanner). Dry-run by default,--applyto perform, an append-only manifest with--revert, and--only-genrefor a staged rollout. Loose single tracks are wrapped in aSingles/folder, and artist-level cover art follows the artist to its genre rather than being orphaned.mv-only on one filesystem, so audio bytes and embedded ratings are never rewritten.
- Fixes: Fixed a flaky
test_plain_when_not_a_ttytest that failed when the suite was run under a pseudoterminal. - Typing: Added a missing
type: ignorefortqdminutils.pyand correctly scoped the ignore forOggOpusintags.pyto ensuremypyruns completely clean.
(Companion-script change; no package version bump.)
- Compilations are excluded from the genre authority. An album whose album-artist is
Various Artists(orVA/Various) has no single canonical genre, so enforcing one would wrongly flatten the disc.buildnow writes such artists as a flagged# ... EXCLUDED (compilation)comment instead of an enforceable row, andapplyhard-skips them even if a stale row exists. The shippedartist_genre_defaults.tsvhad itsVarious Artistsrow converted to the exclusion comment. (Lattice's tag layer already prefers the album-artist, so this keys off album-artist.)
(Companion-script addition; no package version bump.)
- New
scripts/rerate.py: reconcile DeaDBeeF and foobar2000 MP3 ratings. The two players store MP3 ratings in an ID3 POPM byte on different scales, so a rating set in DeaDBeeF reads one star too high in foobar: 2★ (byte 127) shows as 3★, 4★ (byte 254) shows as 5★.rerate.pyrewrites those bytes to the foobar values that both players read identically (127→64,254→196), so DeaDBeeF ratings sift correctly in foobar without changing DeaDBeeF's own display (byte 196 reads 4★ in both, verified on real files). MP3/POPM-only; foobar's own bytes, MusicBee's186/242, unrated, and non-MP3 files are left alone, and Vorbis/Opus ratings already agree.--dry-run, append-onlyrerate.logrecording every change (so a run is auditable and reversible), idempotent. Covered bytests/test_rerate.py.
(Companion-script change; no package version bump. Refinements learned from a real-library run.)
- Accurate
--dry-run. A dry-run now tracks virtual removals, so itsRMDIR/retain lines and summary counts match the real run instead of misreporting emptied folders as retained. - Survivor names are normalized. The folder with the most files still wins as canonical, but its name is now rewritten to a normalized form afterward (so merging a unicode-hyphen
Drive‐By Truckersno longer leaves the non-standard name as the survivor). Uses a new narrowcanonical_renderfold deliberately kept filesystem-safe for NTFS/exFAT targets shared with Windows: only broken hyphens (U+2010/11/12/15) and curly single-quotes/apostrophes go to ASCII. En/em dashes (correct in ranges like85–92), curly double quotes (straight"is forbidden on Windows), the ellipsis glyph (...would end a name in dots, which NTFS rejects), and prime marks are all preserved. Distinct fromnormalize_name, which stays aggressive for duplicate matching. - Rename safety. A rename whose target would be illegal on Windows/NTFS/exFAT (forbidden
<>:"/\|?*, or a trailing./space) is skipped and logged; anyOSErrorduring a rename is caught and logged so a single bad name can never abort the run mid-way. - Higher-resolution cover wins. On a cover-image collision (
.jpg/.png), the higher-resolution file is kept rather than blindly keeping the canonical folder's copy (ties or unparseable images fall back to larger bytes). Dimensions are read with a stdlib JPEG/PNG parser ported from the package. Other non-audio collisions are unchanged. --normalize-names(opt-in). A third pass renames lone, non-duplicate folders whose names carry non-standard characters (e.g.At the Drive‐In→At the Drive-In) to the same normalized form. Off by default; preview with--dry-run. Covered by expandedtests/test_cleaner.py.
(Companion-script addition; no package version bump.)
- New
scripts/genre_tidy.py: artist→genre authority + reconciler. A two-phase companion for libraries whose genre tags have drifted.buildscans (read-only, through lattice) and writes an editable tab-separatedgenre_map.tsv: oneArtist<TAB>Genre<TAB>Genre2<TAB>...line per artist listing every genre that artist currently uses (most-common first), with#comments giving per-genre counts for multi-genre artists. Every listed genre is allowed, so a fresh map makesapplya no-op; you tidy by removing a stray genre from a line, andapplythen callsretag.pyto collapse that artist's albums tagged with the removed genre to the first (canonical) genre. Reorder to retarget, blank a line to skip an artist.--dry-runand append-only logging like the other companions, idempotent. The lattice package stays read-only; every write goes throughretag.py. Keys on the normalized artist tag, not folder name. A real ~877-artist map ships atartist_genre_defaults.tsvas a worked example and maintained authority (re-runbuildto append new artists; hand-edit to accept new genres). Covered bytests/test_genre_tidy.py(21 cases); the same work backfilledtests/test_retag.pyandtests/test_cleaner.py, so all threescripts/companions now have tests.
--rootis now repeatable.lattice --duplicates --root /mnt/A --root /mnt/Bwalks both libraries in one pass; a path passed twice is de-duped. Every mode (trees, wings, stats, audits, art, playlists) aggregates across the roots.- Cross-library duplicate detection. With more than one root, an album that lives in two libraries is grouped as a single exact duplicate. Each entry is prefixed by its root's basename (
Music/...vsRin's Music/...) so the two copies are distinguishable; single-root reports are unchanged. - Optional config array. A
library_rootsarray in~/.config/lattice/config.jsonsupplies default roots when no--rootis given. The first-run prompt still saves only the singlelibrary_root, so a throwaway--rootis never persisted.
- Colorized status summaries. Integrity summaries print a green all-clear, yellow suspect counts, and red corrupt counts. Color is gated on an interactive terminal: off inside the TUI, off when piped or redirected, off under
NO_COLOR, so report files and pipes stay byte-clean.
retag.py: stale genres fully cleared (the deadbeef trap). A genre can hide in more than the standard ID3TCONframe: an APEv2 tag, the ID3v1 genre byte, and a bare customTXXX:GENREframe. The oldEasyID3path left those overrides in place, so players like deadbeef kept showing the old value. The MP3 path now clears all of them and writes one cleanTCON(v2.3, refreshed ID3v1), while deliberately preserving qualifiedTXXXframes (AcousticBrainzAB:*,ALBUMGENRE, MusicBrainz).cleaner.py: apostrophe-fold fix.normalize_namenow strips apostrophes and collapses whitespace after the existing NFKC/dash/quote/case folding, soDirector's CutandDirectors Cutconsolidate. (Closes the 2026-05-25 found bug.)
- Richer report pager. The in-TUI viewer every report passes through now supports PageUp/PageDown, Home/End, and vim g/G on top of line scrolling, uses the full terminal height, and widens to the longest line (up to the terminal width) instead of a fixed 80 columns so long paths are no longer truncated. Folded in from the CalibreQuarry TUI.
- Python 3.14 modernization. With the floor at 3.14, legacy
typinggenerics were dropped for builtin generics and PEP 604 unions (Dict/List/Tuple[...]todict/list/tuple[...],Optional[X]toX | None) across the package and scripts; the redundantfrom __future__ import annotationswas removed fromcleaner.pyand onesubprocess.runsimplified tocapture_output=True. - Expanded test suite. Added a committed fixture library (
tests/fixtures/) and integration tests (tests/test_modes.py) that run the report modes end to end, plus decode-classifier tests. 78 stdlibunittesttests; run withpython -m unittest discover.
- Companion scripts moved to
scripts/. Invokecleaner.pyandretag.pyas./scripts/cleaner.pyand./scripts/retag.py. They remain outside thelatticepackage (spec §5). - Documentation pass. Recast em-dashes out of the prose, trimmed marketing language, fixed an unclosed README code fence, and condensed the per-mode sections.
Running every mode against a real 8,400-file library exposed the integrity scanners crying wolf: they treated any line ffmpeg wrote to stderr as a failure, producing roughly 187 flags where essentially no file had damaged audio. This release reworks them.
- Forced demuxer.
--testMP3,--testOpus,--testWAV,--testWMA, and the FLAC ffmpeg fallback now force the demuxer from the file extension. ffmpeg's format autodetection had been mis-probing valid MP3s with large ID3v2 tags as RIFF and reporting bogus failures; forcing-f mp3(and so on) eliminates the whole class. On the test library this turned dozens of false positives into zero. - Cover art ignored.
-vndrops non-audio streams, so a malformed embedded image is no longer decoded and counted as an audio fault. - Severity tiers. Each file is classified CORRUPT / SUSPECT / METADATA / OK
instead of pass/fail. CORRUPT means the decoder could not get through the file,
or a FLAC lost sync before its declared sample count (true truncation). SUSPECT
means it decoded to the end but the tool complained (these usually play).
METADATA means only tag-parse warnings; the audio is fine. CORRUPT and SUSPECT
are always listed; METADATA and OK are summarized and listed only with
--verbose. - Exit code change. Integrity modes now exit
1only when a file is CORRUPT, not whenever the decoder printed anything. Scripts relying on the old "exit 1 means any complaint" behavior should read the tier counts in the report instead. - FLAC reporting. A failed verification now shows the preferred tool's
message (libFLAC's "decoded N of M samples" rather than ffmpeg's terse "invalid
sync code"), and the mode warns when
flacis absent and the stricter ffmpeg fallback is used. The FLAC report is now always written, not only on failure.
- Added
tests/test_integrity.pycovering the decode classifier across all four tiers using real ffmpeg and libFLAC stderr signatures.
- Latent
Tupleimport in the art-quality audit:modes/artwork.pyannotated_get_image_sizewithTuplewithout importing it. The code ran only because Python 3.14 defers annotation evaluation;typing.get_type_hints, a type checker, or any pre-3.14 interpreter would have raisedNameError. The import is now present.
- Test suite added. A stdlib
unittestsuite undertests/(no third-party dependencies) covers the pure helpers: rating and key normalization, duration clustering, JPEG/PNG header parsing, filename cleanup, and track-number parsing. Run it from the repo root withpython -m unittest discover. - Library and stats refactor. The three duplicated walk-and-aggregate loops and two identical tree-writers in
modes/library.pywere unified behind_scan_album_dirs,_song_display_name, and_write_tree; the file shrank by roughly a third with byte-identical output. The repeated rating-bucketing block inmodes/stats.pywas replaced with a single label helper. Output across every mode was verified unchanged against a captured baseline. - Formatting and lint. The package was run through
ruff format, unused imports were removed, and the misplaced mid-file imports intui.pywere moved to the top.
spec.mdnow lists all shipped modes;--testWAV,--testWMA,--auditArtQuality,--auditBitrate, and--playlistwere missing from its table. A stalepython Lattice.pyexample in the README was corrected, and the test suite is now documented.
- Expanded
--duplicates: The duplicate detection mode was rewritten to emit a four-section report instead of a single album-level list. Section 1 (exact album duplicates) continues to flag the same artist/album pair across multiple directories, but now aggregates the most-common artist and album across every track in the folder rather than sampling the first audio file; per-location lines include the format breakdown, average bitrate, and total size to support keep/discard decisions. Section 2 (within-directory multi-format) reports folders that hold the same track in two or more formats (e.g.,01 - Track.flacnext to01 - Track.mp3), listed track-by-track so partial overlaps stay visible. Section 3 (similar-name candidates) flags within-artist album pairs whose names match at adifflibratio of 0.85 or higher after stripping trailing parentheticals ((Deluxe Edition),(Remastered)) andfeat.clauses; this catches cases likeDomesticavsDomestica (Deluxe Edition)that exact matching misses. Section 4 (track-level duplicates) reports the same artist + title appearing in two or more directories, partitioned into duration-clusters within a 2-second window so a studio cluster and a live cluster for the same song surface as separate rows instead of being lumped together (or one of them silently dropped). - Quote / dash normalization in matching: Album and artist keys now apply NFKC normalization plus the same curly-quote and dash-variant fold table that
cleaner.pyuses ('→',‐/–/—→-, etc.).JAY‐ZandJay-Zcollapse to the same key, so the two are reported together instead of slipping past as separate albums.
- Minimum Python is now 3.14. Lattice was previously declared
>=3.9. The bump is for runtime quality, not language sugar: end users get faster CLI cold starts (cumulative ~25% startup improvement since 3.11, with continued specializing-interpreter gains through 3.14), fine-grained tracebacks (PEP 657) so tag-read or subprocess failures point at the exact column rather than just the line, and faster general bytecode performance from the 3.11+ specializing interpreter. No 3.14-specific language features (template strings, free-threading, tail-call interpreter, etc.) were adopted because they either require a non-default build or have no use case in Lattice's read-walk-report workload.
run_duplicatesfirst-file bias: Reading album/artist tags from only the first audio file in a directory would mis-key entire folders when track 1 had bad tags, was a hidden track, or the album was a compilation with per-track artists. The new aggregation reads tags from every file and takes the mode across the directory.- Empty-album false positives: The exact-duplicate section accepted directories with an empty album key (e.g., singles or weirdly tagged folders), grouping every album-less folder for an artist together as "duplicates." Both
norm_artistandnorm_albumare now required to be non-empty for inclusion in the exact group. - Multi-format display title lowercasing: When tracks lacked title tags, the within-folder multi-format section fell back to the normalized lookup key for display, which had been lowercased. Filename stems are now carried alongside the key and used as the display fallback, so case is preserved.
- Track-level cluster dropping: Track-level dupe detection previously returned only the single largest duration cluster per
(artist, title)key, silently discarding a second valid cluster when the same title legitimately existed as both a studio version (in two albums) and a live version (in two more). Replaced with a partitioning helper that returns every cluster with 2+ entries spanning 2+ directories. - Removed dead code:
_DirInfo.track_countwas computed but never read; removed._fmt_sizehad an unreachable finalreturnafter a loop that always returned in its last iteration; loop refactored to only iterate over B/KB/MB with GB as the natural fallthrough.argparse.BooleanOptionalActionfallback incli.pypredates 3.9 and was dead even at the prior 3.9 floor; removed.
Companion Script: cleaner.py. Added a standalone consolidator for fragmented album folders to the repository. It detects sibling directories whose names differ only in quote rendering (' vs '), dash/hyphen variant, case, or whitespace (the typical artifact of inconsistent metadata across import sources), and merges them via filesystem mv only. Audio collisions where sizes differ keep both copies (source renamed with a .from-fragment suffix), never overwriting user audio. Includes a --dry-run preview mode and per-file logging to <directory>/cleanup.log. Intentionally narrow scope: it does not rewrite tags or re-encode audio. Lives outside the lattice package alongside retag.py, preserving the package's read-only contract.
- TUI Artwork Submenu: Restored missing "Audit art quality" option to the interactive TUI menu.
- TUI Integrity Submenu: Added missing "Test WAV files" and "Test WMA files" options to the interactive TUI menu.
- TUI Metadata Submenu: Added missing "Audit bitrates" option to the interactive TUI menu.
- WAV/WMA Integrity Modes: Fixed a crash caused by missing
DEFAULT_WAV_OUTPUTandDEFAULT_WMA_OUTPUTimports during--testWAVand--testWMAruns. - Genre Split Formatting: Refined genre splitting logic in
write_all_wingsto correctly extract multiple genres without splitting literal paths or bracketed tags when saving library files.
- Dual-Genre Wing Splitting: The
--ai-wingsand--all-wingsmodes now intelligently split dual-tagged items (e.g.,Coke Rap/Midwest Rap). Instead of creating a single, combined.txtfile for the multi-genre string, Lattice now separates the genres and correctly filters the album into both respective genre text files, ensuring accurate categorization across the library.
- Retag Tool Overhaul: Fixed an issue where
retag.pywas duplicating genre tags instead of replacing them. The tool now safely clears APEv2 tags from MP3s, correctly pops all existing standard/custom genre keys across formats, and explicitly forces ID3v1 synchronization to prevent ghost tags in older media players. - Null Byte Sanitization: Fixed an issue in all library generation modes (
--library,--ai-library,--ai-wings,--all-wings) where non-legible null bytes (\x00) from ID3v2.4 multi-value frames were being printed into the text output. Multiple values (e.g., dual genres) are now properly joined with a slash (/). - Wing File Names: Improved file name generation for
--ai-wingsand--all-wingsto preserve word boundaries when encountering slashes or special characters (e.g.,Coke_Rap_Midwest_Rap_AI.txtinstead ofCoke_RapMidwest_Rap_AI.txt).
- Album Overcounting Fix: Resolved an issue where tracks in "Various Artists" or soundtrack directories were being counted as separate albums. All library generation modes (
--library,--ai-library,--all-wings,--ai-wings) now correctly group tracks by their containing directory. - Improved Metadata Consolidation: For each directory, the toolkit now automatically determines the most frequent artist, album title, and genre to use for headers, ensuring accurate representation even when track-level tags vary.
- AI Wings: Added
--ai-wingsto generate separate, token-efficient library files for each genre. These files hide individual songs and only include Artist, Album, Genre, and Directory Location, making them ideal for large-scale LLM processing or quick library overviews. - TUI Submenu Expansion: The Library Tree & Exports submenu now includes both "Generate AI wings" and the previously omitted "Generate smart playlist" options.
- TUI Persistence Fix: Fixed an issue where the TUI would exit or "blink" back to the menu when running background tasks (like Stats). This was caused by the progress bar calling
curses.endwin(), which terminated the curses session prematurely. - Improved Progress Bar: The TUI progress bar now correctly updates within the existing curses session without corrupting the terminal state.
- Stats Page Fix: Fixed a
NameErrorin the statistics module wheregenre_ratingswas not properly initialized. - Missing Report: Properly implemented the "Rating Distribution per Genre" report section in
--statswhich was previously omitted. - Version Synchronization: Corrected version mismatches across the repository.
Lattice now supports dynamic directory structures via the --layout flag, completely decoupling library generation from the strict ARTIST/ALBUM assumption. You can now generate .m3u playlists using rule-based filters.
- Configurable Layout: A new
--layoutargument specifies your directory structure (e.g.{genre}/{artist}/{album}).write_music_library_tree,write_ai_library, andwrite_all_wingsnow intelligently parse paths according to this structure if tags are missing. They no longer fail or produce garbage output on flat folders. - Smart Playlists: Generate
.m3uplaylists based on dynamic evaluation rules using--playlistand--rule(e.g."rating >= 4 and genre == 'Jazz'"). - WAV & WMA Support: Extended the unified FFmpeg decode scanner to verify WAV (
--testWAV) and WMA (--testWMA) files. - Art Quality Audit: Added
--auditArtQuality(with configurable--min-art-res) to parse and report extracted or embedded covers falling below a minimum resolution threshold (default: 500x500). - Bitrate Floor Audit: Added
--auditBitrate(with configurable--min-bitrate) to report audio files falling below a designated kbps floor (default: 192). - Rating Distribution per Genre: The library statistics page (
--stats) now cross-tabulates rating distributions (e.g., 5-star vs 1-star spread) independently per genre.
- TUI Close Button Fix: Fixed an indexing error in the interactive menu where selecting "Quit" would accidentally trigger the "Change library root" prompt due to a missing settings group in the main
_MAIN_SECTIONSlist.
- Fixed an issue in the TUI main menu where selecting "Quit" (or pressing 'q') would unintentionally trigger the "Change library root" prompt due to a mismatched menu array index.
- Fully Immersive TUI: Addressed an issue where background operations (such as cover art extraction or tree generation) would write their output directly to the terminal stdout and pause, which dropped the user out of the full-screen curses environment.
- The TUI now features a global output capture wrapper (
_run_with_capture) using anio.StringIObuffer. - Standard output and error output are automatically intercepted while a background task executes, allowing progress bars to draw undisturbed.
- Upon task completion, any logged output (e.g., dry-run details, success messages, errors) is formatted and displayed within the
_tui_pageviewer, ensuring the user never leaves the curses application.
- The TUI now features a global output capture wrapper (
- Fixed a rendering bug where
_TUIPbardid not erase the screen on its first draw, causing overlapping text from previous prompts in the curses interface. - Fixed a crash (
ValueError: embedded null character) when scrolling through the library statistics TUI page by sanitizing null bytes from the output report.
- First-Run Configuration: Added a persistent configuration file stored at
~/.config/lattice/config.json.- The CLI and TUI now save the root music library location upon first run, eliminating the need to repeatedly specify
--rootor manually enter the path in the interactive menu. - A new "Change library root" option has been added under the
SETTINGSsection in the TUI main menu. - If no
--rootis provided, the CLI gracefully falls back to the configured location (or prompts if unconfigured).
- The CLI and TUI now save the root music library location upon first run, eliminating the need to repeatedly specify
- TUI Immersion Enhancements:
- Progress bars now render inside a stylized curses box when running from the TUI, preventing screen tearing and keeping the interface consistent.
- The library statistics page now displays its full report in an integrated, scrollable curses pager (
_tui_page), rather than dropping you back into standard terminal output.
- PyInstaller Multiprocessing Fix: Fixed an issue where the standalone binary would crash (
unrecognized arguments: -B -S -I -c) on Python 3.14 due to themultiprocessing.resource_trackertrying to spawn a new process using the executable as the Python interpreter. The executable now properly intercepts-ccommand strings from the tracker. - Positional Root Argument: The CLI now supports providing the root directory as an optional positional argument. You can run commands like
lattice --library .instead of explicitly using--root ..
Lattice has been completely refactored from a single ~2500-line monolithic script (Lattice.py) into a proper, modern Python package architecture.
Layer-Based Package Design. The codebase is now housed in src/lattice/ and split by logical functionality (cli.py, tui.py, tags.py, utils.py, config.py, and a modes/ directory for individual feature operations). This dramatically improves maintainability while preserving the exact same functionality and CLI interface.
Modern Build System (Hatch). Lattice now uses pyproject.toml managed by Hatch, replacing the need for manual pip install mutagen tqdm commands. You can now cleanly install Lattice via pipx install . and have the lattice command available globally in your terminal.
Standalone Native Executable. We have integrated PyInstaller support to compile Lattice into a self-contained standalone binary. This means end-users no longer need to install Python or external packages (like mutagen) on their machines. The compiled binary (lattice) can be dropped into any directory in your PATH.
Absolute Paths for Genre Wings. The --all-wings mode now accepts a --paths flag. When enabled, the absolute directory path is appended to the album header in the generated text files (e.g., ALBUM: Jane Doe [/path/to/Music/Converge/Jane Doe]).
- This bridges the gap between visualization and execution. It eliminates the need to write brittle shell scripts that guess file locations by scraping artist and album strings. You can now pipe the generated wing files directly into command-line tagging utilities.
- The interactive TUI's Library submenu has been updated to prompt for path inclusion (
Include paths? (y/N)) when generating genre wings. Companion Script:retag.py. Added a standalone universal genre tagger to the repository. It abstracts away container-specific tagging differences (ID3, Vorbis, Apple atoms) and is designed to cleanly consume the absolute paths generated by the--all-wings --pathsflag. This allows for safe, bulk-overwriting of genres at the album-directory level.
Album Artist Prioritization. Fixed an issue where albums were being split up due to featured artists on individual tracks. The tag extractor now consistently prioritizes "Album Artist" over "Artist" across all supported formats:
- MP3:
TPE2>TPE1 - FLAC/Ogg/Opus:
albumartist>artist - MP4/M4A:
aART>\xa9ART - ASF:
wm/albumartist>author
The entire interactive experience now stays in curses. Previously, selecting a
menu item dropped to raw input() calls for parameter prompts (root directory,
output file, worker count, etc.) and the post-operation pause, breaking the
visual flow. All prompts and the pause screen now render inside the same styled
Unicode boxes as the menus.
Curses prompts. _tui_prompt_str draws a centered box with a yellow header
label and a cursor-visible input field. Typing, backspace, Enter to confirm,
Esc to accept the default, all within the curses session. Since _prompt_path
and _prompt_int call _prompt_str internally, every parameter prompt in the
interactive menu gets the TUI treatment automatically.
Curses pause. _tui_pause replaces the raw input("Press Enter…") with a
styled box. Accepts Enter, q, or Esc to dismiss.
Fallback preserved. If curses is unavailable or stdin is not a TTY,
prompts and pause fall back to plain input(), same as before.
All CLI flags (--library, --ai-library, --all-wings, etc.) are unchanged.
The interactive menu is now a full-screen curses TUI with arrow-key navigation,
color-coded sections, and a highlighted selection cursor (►). No more typing
numbers; just ↑/↓ to move, Enter to select, q or Esc to quit.
The menu is drawn as a centered Unicode box with labeled section groups: Library (yellow), Integrity, Artwork, and Metadata, separated by ruled dividers. The selected item is highlighted in bold cyan reverse video. A hint bar at the bottom shows available controls.
Library submenu. AI-readable library export and genre wings (all-wings)
are now nested under a "Library tree & exports" submenu (marked with →)
alongside the standard library tree builder. Selecting it opens a second
curses menu; Esc returns to the main menu. This trims the top-level menu
from 11 flat items to 10 navigable entries and groups the three library-output
modes where they logically belong.
Curses colors:
- Cyan box frame
- Bold yellow section headers
- Bold cyan-on-black highlight for selected item
- Dim hint bar
Fallback path. If curses is unavailable (e.g. windows-curses not
installed) or stdin is not a TTY, the menu falls back to a static boxed
text display with numbered options and typed input, the same layout, just without
arrow-key navigation.
Post-operation pause. Every mode now waits for Enter before redrawing the menu, so results aren't immediately scrolled off screen.
Indented prompts. All interactive prompts are visually aligned with the menu box for a tighter feel.
All CLI flags (--library, --ai-library, --all-wings, etc.) are unchanged.
--all-wings scans genre tags across the entire library, groups albums by
genre, and writes a separate library tree file for each genre into an output
directory, analogous to virtual library wings in Calibre's getBooks.
lattice --all-wings --root ~/Music --output wings/Produces files like Alternative_Rock_Library.txt, East_Coast_Rap_Library.txt,
etc. Albums with no genre tag land in Uncategorized_Library.txt. Each file
uses the same tree format as --library. Pass --genres to include the genre
label in album headers. Available from both CLI and interactive menu (option 11).
The --ai-library export no longer overrides the directory-based artist name
with tag data. Previously, the artist field fell back through TPE1 → TPE2
(ALBUMARTIST) from tags, which added noise without value; the AI export
doesn't distinguish album artist from track artist, and the directory name is
the canonical artist in a well-organized library. This keeps the output cleaner
and more predictable.
--ai-library generates a flat, token-efficient summary of the music
library for use in LLM recommendation prompts. One line per album in
pipe-delimited format:
Artist | Album | Genre | Rating | Tracks
--------------------------------------------------
Converge | Jane Doe | Metalcore | 4.8 | 12
- Rating is the average of all rated tracks in the album, rounded to one decimal. Blank if no tracks are rated.
- Tracks is the number of audio files surviving in the album directory:
the post-cull headcount. An AI reading
5.0 | 1vs4.6 | 12gets the density signal without extra framing. - Genre is sampled from the first track with a genre tag.
- Output defaults to
library_ai.txt. Available from both CLI and interactive menu (option 10).
get_all_tags reduced to a single MutagenFile open per file. The v2.1.0
unified reader still opened each file twice, once via the EasyID3 abstraction
pass, once via the full format-specific path (because rating, duration, and
bitrate aren't available through the easy interface). The easy pass is now
eliminated entirely; all tag extraction runs against the single full object.
The MP3 branch also had a separate ID3(file_path) call on top of the
MutagenFile open; that call is now removed, and tags are read from audio.tags directly.
On a 6,300-track library, this eliminates ~12,600 redundant file opens per full-library mode.
TagBundle extended with duration_s and bitrate_kbps. These fields are
extracted from audio.info during the same single open. run_stats previously
opened every file a second time just to read duration and bitrate; that
redundant open is gone.
First-song double-read eliminated in --library --genres. Genre was read
from the first song before the per-track loop, then the loop re-read the same
file. The album header is now deferred until the first track's tags are available
inside the loop.
count_audio_files was called twice in --ai-library. Once for the console
message, once for the progress bar. Now called once, result reused.
_has_embedded_art duplicated _extract_best_art's directory scan logic.
Collapsed to a one-liner: return _extract_best_art(directory) is not None.
Low-quality bitrate count in --stats used a list comprehension just to
call len(). Replaced with a generator sum.
--root ~/Music didn't work from the CLI. main() was missing
os.path.expanduser(); tilde expansion only worked in the interactive menu.
--library --output subdir/file.txt crashed. write_music_library_tree
opened the output file directly without creating parent directories, unlike
every other mode. Added os.makedirs.
_find_files_by_ext_path matched false extensions. Used
filename.endswith('.flac') which would match a hypothetical file named
notflac. Replaced with os.path.splitext for exact extension matching.
Rating bucketing in --stats used Python's round() (banker's rounding).
A 4.5 rating rounded to 4, but so did 3.5. Replaced with int() (truncate)
for consistent behavior matching the star display logic in format_rating.
Unified MP3/Opus decode scanner. _scan_one_mp3, _scan_one_opus,
run_mp3_mode, run_opus_mode, and _format_mp3_meta collapsed into three
shared functions: _scan_one_file, _run_decode_scan, and _format_row_meta.
Format-specific behavior is parameterized via ext, enrich, and
ffmpeg_required flags. Adding a new format is now a three-line wrapper.
_FallbackProgress class replaces all if pbar: conditionals. _make_pbar
now always returns an object with .update() and .close(), whether tqdm is
installed or not. Eliminated 6 conditional blocks and 4 dead counter variables
(current_file, checked, scanned_count, current) that existed only to
feed the manual fallback.
is_audio() helper. Replaced 6 inline
os.path.splitext(f)[1].lower() in AUDIO_EXTENSIONS patterns. Two callsites
where ext was already extracted for other purposes were left as-is.
_looks_numeric() helper. Replaced 4 inline
str(val).replace('.', '').isdigit() patterns in rating extraction code.
_prompt_path() helper. Consolidated 8 identical
os.path.abspath(os.path.expanduser(_prompt_str(...))) patterns in the
interactive menu.
test_flac simplified. Two mirrored if/elif branches (one per tool
preference) collapsed into a priority-ordered tool list with a single loop.
Four standalone tag functions removed (~190 lines). get_title_artist_track,
get_album, get_genre, get_rating, all superseded by get_all_tags in
v2.1.0 but left in the codebase. No internal callers remained.
_get_cover_file_path: defined but never called by any mode.
_scan_one_mp3, _scan_one_opus, _format_mp3_meta: replaced by the
unified scanner.
ID3 and ID3NoHeaderError imports: no longer needed after the MP3
branch was rewritten to use audio.tags from MutagenFile.
Stale (NEW) markers removed from section headers.
Opus mode wrote to mp3_scan_results.csv. Copy-paste bug in _write_header
hardcoded DEFAULT_MP3_OUTPUT as the fallback filename for all modes. Opus scans
silently wrote results to the wrong file.
_extract_art_from_mp3 called MUTAGEN_MP3 without checking
HAVE_MUTAGEN_MP3. If mutagen installed but mutagen.mp3 failed to import,
art extraction threw NameError.
verbose flag mutated only_errors and quiet inside the MP3 scan loop.
Dead writes on every iteration after the first. Moved above the loop. Opus mode
now has the same verbose behavior for consistency.
Terminal corruption after subprocess modes. Running FLAC/MP3/Opus integrity
checks from the interactive menu left the terminal with icrnl disabled; Enter
sent ^M instead of newline, and input froze. Caused by run_proc using raw
bytes mode while flac -t wrote binary diagnostic data to stderr, colliding
with tqdm's cursor manipulation. Fixed with _reset_terminal() (stty sane)
called at the top of every menu loop, and in a finally block on CLI exit.
Unified tag reader: get_all_tags() → TagBundle. The old code opened each
file up to 4× via independent MutagenFile() calls (get_title_artist_track,
get_album, get_genre, get_rating). Consolidated into a single function
returning a TagBundle named tuple. ~19,000 fewer file opens per --library
run on a 6,300-track library. Callers updated: write_music_library_tree,
run_tag_audit, run_duplicates. Original standalone functions preserved for
any external imports but no longer called internally.
Duplicate file-finder eliminated. find_files_by_ext (string generator) and
_find_files_by_ext_path (Path list) did the same job. Removed the former,
pointed FLAC mode at the latter.
Vestigial paths list removed from run_mp3_mode. Leftover from a
multi-root design that never shipped. Replaced with a plain root_path.
Counter import consolidated. Was at module level via defaultdict but then
re-imported locally in run_tag_audit. One import, one location.
Removed unused Iterable from typing imports.
All output modes now write .txt reports instead of .csv. None of these
outputs were destined for spreadsheets; they're checklists and diagnostics
read by one person, and the format now respects that.
- FLAC/MP3/Opus integrity: Header with scan totals, results grouped by severity (ERRORS → WARNINGS → OK). Relative paths, tool/error details, compact metadata where relevant (bitrate, sample rate, duration).
- Missing art: Two sections: no art at all, embedded only. Relative paths with file counts.
- Duplicates: Grouped by artist/album pair with directories nested underneath showing format sets.
- Tag audit: Grouped by directory, each file showing format and missing fields. Header includes field-level breakdown counts.
_write_header / _close_writer / _rotated_path: Entire CSV writer
infrastructure gone. These managed csv.DictWriter lifecycle via a
monkey-patched _file_handle attribute. With text output, file writes are
straightforward open() calls.
import csv: No longer imported.
Lattice.py is now a single unified toolkit. The standalone
extract_opus_art.py and extract_mp3_art.py scripts are retired; their
functionality lives in the main script as --extractArt, with improvements.
--testOpus: Opus file integrity checking via FFmpeg decode (same pattern as--testMP3).--extractArt: Extract embedded cover art tocover.jpgwith format priority ranking (FLAC > Opus > M4A > MP3) and--dry-runsupport.--missingArt: Report directories with no cover art (distinguishes "no art at all" from "embedded only").--duplicates: Detect same artist+album appearing across multiple directories or formats.--auditTags: Report files missing title, artist, track number, or genre with a summary breakdown.
- Fixed cover.jpg collision: Cover detection is now case-insensitive.
Running art extraction in a folder with both Opus and MP3 files no longer
produces both
cover.jpgandCover.jpg.
- Art extraction prefers front cover (type 3) over generic embedded images.
- Art extraction supports four formats: FLAC, Opus/OGG, M4A, MP3.
- Interactive menu updated with all eight modes.
- All existing CLI invocations remain backward-compatible.
extract_opus_art.py(folded into--extractArt).extract_mp3_art.py(folded into--extractArt). les no longer produces bothcover.jpgandCover.jpg.
- Art extraction prefers front cover (type 3) over generic embedded images.
- Art extraction supports four formats: FLAC, Opus/OGG, M4A, MP3.
- Interactive menu updated with all eight modes.
- All existing CLI invocations remain backward-compatible.
extract_opus_art.py(folded into--extractArt).extract_mp3_art.py(folded into--extractArt). rt`).