Carry-backs from the cquarry review: cquarry finished porting the 2026-07-01 audit, and its adversarially verified code review confirmed four defects in the shared curses skeleton that v4.10.0 still carried. The fixes mirror cquarry's (CalibreQuarry 0304cfa). One deliberate behavior change is called out below.
- Fix: the "Report written to ..." footer no longer appears after an error or cancel.
_run_with_captureappended the success footer unconditionally, so the pager could show[Error]plus a traceback (or[Cancelled]) followed by a footer claiming a file that was never finished. The footer is now suppressed whenever the mode raised or was cancelled. - Behavior change: Ctrl-C at the text-fallback menu exits 130, not 0.
_fallback_inputswallowedKeyboardInterruptinto a clean Quit, so a wrapper script checking for the documented 130 saw success. The interrupt now propagates to the session handler (which already maps it to 130), matching the curses menu; EOF remains a quiet Quit. Verified end-to-end: SIGINT at the degraded text menu exits 130. - Fix: the prompt and pause curses-failure fallbacks delegate instead of duplicating.
_tui_prompt_str'scurses.errorhandler re-implemented the text prompt inline (and both copies rendered aNonedefault as[None]); it now degrades and calls_prompt_str, which renders an empty default as[]._tui_pauselikewise delegates to_pause. - Cleanup: widget color init runs once per screen, not per widget. Every widget
_runre-called_init_tui_colors()although the session screen already initialized colors; the one-shot init now lives in_with_screen's wrapper branch (session screens get it from_open_screen), and the four per-widget calls are gone. No behavior change. - Found and fixed while verifying: the library submenu broke the persistent screen with
stty sane._library_submenuran_reset_terminal()unconditionally after every selection; under the v4.10.0 session screen that re-enabled echo and canonical mode, so the very next prompt echoed keystrokes over the TUI and only saw input after Enter. The v4.10.0 pty verification drove a main-menu flow and missed it. Fixed at the source, matching cquarry's shape:_reset_terminalnow returns early while a session screen is published (utils._SHARED_SCREEN), since a live curses session owns the terminal state. CLI use is unchanged (no session screen there). Re-verified under a pty: menu → submenu → prompt → Esc-cancel → quit works with one alternate-screen enter/leave. - Tests extended (
tests/test_tui.py+tests/test_utils.py, 449 total across the suite): footer suppressed on error and cancel (still shown on success),_fallback_inputpropagating Ctrl-C and quietly quitting on EOF, the prompt/pause fallbacks routing through their text counterparts, and_reset_terminalstaying quiet while a session screen is live.
The persistent-curses-screen rework: audit item T7, the last parked entry from the 2026-07-01 TUI/UX audit. Purely a lifecycle change; no menu, prompt, or mode behavior differs.
- One curses screen per session. Every menu, prompt, pause, and pager used to be its own
curses.wrapperinit/teardown, so multi-prompt flows visibly flashed to the shell between widgets.interactive_menunow opens the screen once and every widget draws into it (_with_screen); a widget invoked outside a session still gets its own one-shot wrapper, so nothing changes for direct callers. Measured under a pty: a full menu → prompt → cancel → mode → pager → quit session enters the terminal's alternate screen exactly once, where the same flow on v4.9.0 entered it seven times. - The in-mode progress bar joins the session.
_TUIPbardraws into the shared screen the session publishes (viautils.set_shared_screen) instead ofinitscr()-ing a screen of its own, and itsclose()no longer tears down terminal state that isn't its to tear down. Standalone use (no session) keeps the old start-and-end-a-screen behavior. - One degradation path. A mid-session curses failure (terminal died, capability lost) funnels through
_degrade_to_text: the screen is suspended once and the rest of the session runs the text fallback. Session startup failure does the same before the first menu. All the v4.8.2 guarantees (no stuck terminal, no silent exit) hold. - Ctrl-C is coherent everywhere. At a prompt it cancels exactly like Esc; in the pager it closes the pager; at the menu it ends the session cleanly with exit code 130 (previously an unhandled traceback).
- Tests extended (
tests/test_tui.py+tests/test_utils.py, 441 total): session-screen routing, wrapper fallback, degradation clearing every piece of session state, the 130 exit, and the progress bar leaving a shared screen alone.
TUI parity and UX release, closing the 2026-07-01 audit's T1 to T6 (T7, the persistent-curses-screen rework, stays deliberately parked). One deliberate behavior change is called out below.
- Multi-root configs are visible to the TUI (T1). A
library_roots-only config used to read as a first run: the TUI prompted and overwrote the user's intent with a singlelibrary_root. The session check now consults both keys, the menu title shows[N roots], and every mode receives the full roots list exactly as the CLI passes it. "Change library root" edits onlylibrary_rootand says so when a roots list exists. - Behavior change: Esc in a prompt cancels (T2). Esc used to mean "accept default", so selecting a scan by accident and mashing Esc launched it. Esc (or Ctrl-C at a text prompt) now unwinds the whole prompt chain back to the menu with nothing launched; bare Enter still accepts the default. The hint bar reads "⏎ Accept Esc Cancel Ctrl-U Clear".
- Library-root changes are validated before persisting (T3). A typo'd root was saved over the good one, every retry of the recovery loop was labelled "First run:", and Esc or a blank answer at the true first run silently made the launch directory the permanent root. Paths are now checked with
isdirbeforeset_library_root; a bad answer shows "Not a directory" and keeps the old root; a missing configured root gets its own "Configured root missing" wording; a blank first-run answer re-prompts (type.to choose the CWD explicitly). - Output-path prompts expand
~(T4).~/reports/x.txtused to create a literal./~/reports/. Every output prompt now expands the tilde (paths otherwise stay relative), and the results pager ends with "Report written to /abs/path" so the report's location answers itself. - CLI parity in the prompts (T5). The decode-scan modes gained an "ffmpeg path (blank = auto)" prompt (the CLI's
--ffmpeghad no TUI equivalent); stats gained the layout prompt its siblings have; the FLAC prefer-tool prompt re-asks until it getsflacorffmpeg; the OK-rows question is labelled "(verbose report)" since one answer drives both knobs; and the unreachableor "{artist}/{album}"arms after layout prompts (which would have shadowed a configured layout if ever reached) are gone. - TUI niceties (T6). Bad numeric input re-prompts with a note instead of silently using the default; menus taller than the terminal scroll to keep the selection visible; the pager pans horizontally (←→/h/l) with
…truncation markers and no longer recomputes its width on every keypress; Ctrl-U clears a prompt field; the in-mode progress box is throttled to ~10 redraws/s and showsn/total · Ctrl-C cancels; the Quit/Change-root result tuples are named constants. Mode-level: a bad smart-playlist rule is now rejected once, up front (validate_rule), instead of printing one error per track — the CLI benefits too. - Tests extended (
tests/test_tui.py+tests/test_playlists.py, 436 total across the suite): a hermetic menu harness (config stubbed, pager captured) covering cancel chains, root validation, multi-root passthrough and titling, prefer-tool re-prompting,~expansion, integer re-prompts, and the rule validator.
Polish release from the 2026-07-01 audit (roadmap L-cleaner batch); one behavior change, strictly safer.
- Fix: "identical" duplicates are verified by content sample, not size alone. A colliding file was dropped as a DROP DUPE purely on byte count, so a same-size re-encode or retag of a different take was deleted. The drop now also requires equal first/last 64 KiB (
head_tail_equal); a same-size file with differing sampled bytes takes the collision path instead, so audio survives as.from-fragment. A mid-file-only difference within same-size files can still slip through the sample; the log line says "sampled bytes" for that reason. - Fix: an MP3 with no ID3 header (tags only in APEv2/ID3v1) is reported and counted (
SKIP (no ID3 header ...), newtag_no_id3_skippedstat) instead of silently skipped; the.wavpath was already the model. - Fix: the ASF tag write deletes case-variant keys first. The
.wmabranch read keys case-insensitively but wrote canonical case beside a variant original, leaving two keys; it now clears variants like the Vorbis branch. - Fix: pass headers no longer orphan the log timestamp.
log("\n--- PASS ---")put the timestamp prefix on the blank line; the blank line is emitted separately now. - Fix: a merge survivor that also gets renamed is one tag target, not two. The rename hook and the group both recorded the artist; the group's record (which also carries the merged sources) is now the only one for that case, while Pass 3 sweep renames still record their own.
- Cleanups:
_normalize_folder_name/_normalize_file_nameshare one_rename_toguard helper (H3's fix no longer has to be made twice),consolidate_groupcomputes each folder's file count once instead of walking twice, andfile_countno longer uses_as a load-bearing variable. - Tests extended (
tests/test_cleaner.py, 82 total): same-size-different-bytes collisions,head_tail_equaledge cases, the headerless-MP3 report, the ASF case-variant delete, the log format, and tag-target dedupe (merge+rename vs sweep).
Polish release from the 2026-07-01 audit (roadmap L-genre_foldermap batch). Also corrects a housekeeping miss: yesterday's v1.3.2 changes shipped without bumping __version__ (it still said 1.3.1); this release moves it straight to 1.3.3.
- Fix: the genre gate matches case-insensitively and reuses the existing folder's spelling. A stray tagged
hip hopagainst an existingHip Hopfolder was flagged UNKNOWN GENRE, and--allow-new-genrewould then mint a case-variant duplicate top-level folder. A case-variant match now files into the existing folder (artist-level sidecars follow the same spelling), and an organized album whose tag differs from its folder only by case is no longer a NOTE. - Fix: cross-device moves are refused.
shutil.movesilently degrades to copy+delete across filesystems, against the documented mv-only contract (audio bytes are never rewritten); the Runner now compares device numbers and refuses with aCROSS-DEVICE (refused)line and its own stats counter. - Fix: paths containing a tab or newline are refused, not moved. Such a path would corrupt the manifest TSV, making the move unrevertable; it is flagged
UNSAFE NAME(rename the folder first) and its bookkeeping is skipped. - Docs honest about revert and reversibility. Every
--revertmention now says it is a dry-run by default (add--applyto execute, same as a forward run), including the hint the manifest header writes; the "reversible" claim is scoped to files and moved folders (source folders pruned empty are not recreated); the vocabulary is documented as record-derived (a genre folder with no readable audio that run drops out of the gate). Comment fixes: theMove.kindcomment no longer claims pruning behavior, thedst == srcguard is annotated with its only reachable case (genre folder named like the staging inbox), and the prune comment describes the real single deepest-first pass. - Tests extended (
tests/test_genre_foldermap.py, 55 total): case-variant gate reuse (with and without--allow-new-genre), the case-variant NOTE suppression, unsafe-name refusal, and the cross-device refusal.
Polish release from the 2026-07-01 audit (roadmap L-genre_tidy batch).
- New:
--layouton both subcommands. The scanner was pinned to{artist}/{album}, so on the genre-foldered library genre_foldermap produces, an untagged file's artist fell back to the genre folder name. Pass--layout "{genre}/{artist}/{album}"to recover artist/genre from the right path levels, exactly like cleaner.py. - Fix: tabs and newlines in tag values no longer corrupt the map. A genre like
Rock<TAB>Popwas written as two TSV columns, so it read back as two allowed genres and a fresh map was not a no-op (applyrewrote the tag). Generated fields fold tab/newline to a space;norm()collapses whitespace the same way, so the sanitized row still matches the raw tag at compliance time. - Fix: silent last-wins on normalized-key collisions. Two hand-edited rows that normalize to the same artist (
Jay-Zvs a curly-dashJay‐Z) silently dropped the earlier row; the overwrite warns on stderr now. - Docs: the EXCLUDED trade-off is stated. A real artist actually named "Various" or "VA" is permanently unenforceable; the comment block above
EXCLUDED_ARTISTSacknowledges it. - Tests extended (
tests/test_genre_tidy.py, 40 total): TSV sanitization round-trips, the collision warning, and the layout passthrough.
Polish release from the 2026-07-01 audit (roadmap L-retag batch); no new features. RT4 (the "subdirectories not descended" note) had already landed with M17.
- Fix: direct invocation is byte-idempotent. A file already carrying exactly the target genre was still fully rewritten on every run (APEv2 deleted, v2.4 tag re-saved as v2.3, ID3v1 minted). A per-file no-op guard now skips the write and logs
unchanged; for MP3 the guard also checks the hidden genre spots retag exists to clear, so a matching TCON with a stray APEv2 tag or bareTXXX:GENREframe still gets the write. Dry-run predicts the same skips. (genre_tidy already gated via compliance; this hardens direct use.) - Fix: an unreadable Vorbis-family file reports its failure.
mutagen.File()returning None for a corrupt file used to return False silently, invisible next to every other failure path's[!] Failedline; it prints one now. - Fix: WMA dry-runs show the real old genre.
read_genresusedeasy=True, which has no ASF wrapper, so.wmaalways read back[];WM/Genreis read directly now. - Docs: dead code dropped. The Vorbis branch popped both
"genre"and"GENRE"with a comment about clearing "both cases"; mutagen's comment dict is case-insensitive, so the second pop could never do anything and is gone. - Tests extended (
tests/test_retag.py, 24 total): byte-identical second run, the stray-APE and bare-TXXX force paths, dry-run unchanged prediction, the WMA read, and the unreadable-file report.
Polish release from the 2026-07-01 audit (roadmap L-rerate batch); no new features. RR4 (error accounting) had already landed with M10.
- Fix: hidden directories are pruned from the walk.
.testing/album copies (and any dot-directory) were rerated along with the library; the walker now prunes them, matching replaygain.py. - Fix: a bad
--logpath is a clean error (exit 1 with a message, not a traceback), matching the directory validation. - Docs: the REMAP comment no longer claims extra entries are harmless. They are not: the map is deliberately closed because only DeaDBeeF's 2★ (127) and 4★ (254) have a byte both players agree on. Byte 64 is a fixpoint collision (DeaDBeeF 1★ and foobar 2★ share it, unfixable by byte rewrite alone) and DeaDBeeF 3★ (190) has no byte both players read as 3 stars. The comment now says so, and warns that any candidate entry needs verifying in both players first.
- Docs: the save is described honestly. "Rewrites the POPM byte in place" undersold it; the save re-serializes the whole ID3 tag (v2.4 comes back v2.3, ID3v1 refreshed, matching retag.py), audio untouched.
- Tests extended (
tests/test_rerate.py, 12 total): hidden-dir pruning and the guarded log open.
Polish release from the 2026-07-01 audit (roadmap L-replaygain batch); no new features.
- Fix: an album rsgain silently declined is a NO-OP, not "scanned". Easy mode exits 0 with "No files were scanned" when it finds nothing it can tag; the run counted it scanned and logged nothing unusual. The album's gains are now read back before it counts: no message and no gains means a distinct
NO-OPlog line and its own summary counter. - Fix: M4A gains read back as text.
MP4FreeFormis a bytes subclass, so the post-scan verification loggedb'-6.66 dB'; bytes values are decoded now. - Fix: every run leaves a log trail. A run where everything was skipped (
--skip-taggedon a fully tagged library) or that was declined at the confirmation prompt wrote nothing at all to the log; both now write a properRG RUN START/summary/ENDblock saying why nothing happened. - Fix: a bad
--logpath is a clean error. The log open was unguarded, so an unwritable path was a traceback; it now validates likedirectorydoes and exits 1 with a message. - Docs:
--skip-taggedtarget-blindness stated. It checks only that gain tags exist, not what target they were computed against, so an album tagged at the default 89 dB reads as tagged under--target-lufs -14; the docstring says to rescan without--skip-taggedwhen changing targets. Also tidied: the worklist filter unpacks its tuples instead of indexingw[5]. - Tests extended (
tests/test_replaygain.py, 27 total): NO-OP vs scanned accounting, the bytes decode, skip-everything and declined-prompt log blocks, and the guarded log open; themainharness is now shared between the dry-run and apply test classes.
Polish release from the 2026-07-01 audit (roadmap L-apestrip batch); no new features.
- Fix: unmigratable APE items are reported and skipped, never written as junk frames. Under
--keep-metadata, a binary item bound for a TXXX passthrough became an empty TXXX frame, an external cover reference became anAPICwith zero bytes of image data, and a cover in an unrecognized image format was mislabeledimage/jpeg. All three now land in a per-file[skip]report (with the reason) and are dropped with the tag; the preview summary counts them. - Fix: a described COMM frame no longer blocks a real comment migration. The redundancy check counted any
COMM*frame, so an iTunesCOMM:iTunNORM:engnormalization blob made a sole-source APE Comment read as redundant and silently dropped it; only an unqualified comment (empty description) counts now. - Fix: the raw item parser bounds each value length. A malformed item whose declared length overran the footer would slurp footer bytes into its value; the parse now stops at such an item instead.
- Fix: repair-path fsync ordering. With migrations,
id3.save(tmp)rewrote the temp file after the flush+fsync, so the atomic swap could install a half-flushed tag on power loss; the file is synced again after the save. - Docs honest about the redundancy check. The docstring promised value-level preservation ("so nothing is lost") but the check is presence-only: a field ID3 already has, whatever its value, is treated as authoritative and not overwritten. Now says so. Also tidied: the garbled
_RawAPEValuedocstring, a narration comment, the mid-fileAUDIO_EXTconstant, andplan_file's annotation (it also accepts raw-parsed items). - Tests extended (
tests/test_apestrip.py, 53 total): binary/external/unknown-format skip paths, the iTunNORM redundancy trap, and the overflowing item length.
Bugfix release from the 2026-07-01 audit (roadmap items M1 to M6); no new features.
- Fix:
--keep-metadataon an ID3v1-only MP3 no longer clobbers the v1 values. Without seeing ID3v1, every APE field looked sole-source and the eventualsave(v1=2)rebuilt ID3v1 from the sparse v2 frames, blanking the old title/artist (exactly the old rips most likely to carry APE tags). Migration planning now seeds from the trailing v1 block when there is no v2 tag. On mutagen 1.46+ (which reads v1 intoID3()itself) the bug never fired; the seed covers older mutagen and the test pins the behavior either way. - Fix: multi-value APE text items migrate as separate values. They were flattened to one NUL-joined string, and an embedded NUL truncates a v2.3 UTF-16 frame so players showed only the first value. Values now stay separate through the migration (the v2.3 save renders them slash-joined, all values visible); labels render them readably.
- Fix:
--repair-malformedpreserves file permissions. The atomic-swap temp file (mode 0600) was installed verbatim, silently stripping group/other access on shared-mount libraries; the original mode is copied before the swap. - Fix: a repair-path I/O error is a per-file error, not a crash. An unreadable file or read-only directory raised out of the unguarded repair body and killed a library-wide run mid-write-pass; it now returns an error result the loop's accounting handles.
- Fix: the repair excision recognizes trailing structures. Everything between the APE footer and a strictly terminal ID3v1 used to be cut as junk: a Lyrics3v2 block was deleted, and an ID3v1 followed by stray padding was itself excised (the end-anchor missed it). Lyrics3v2 and a terminal ID3v1 are now preserved byte for byte; junk before a terminal ID3v1 (the shape that motivated the flag) is still excised; anything else refuses the repair and reports instead of guessing.
- Fix: honest audit log and exit code. A file whose APE tag vanished between the planning and write passes was logged as "stripped APEv2 tag"; it now logs "no APEv2 tag at write time (skipped)".
mainexits 1 when any file errored (was always 0). - Tests extended (
tests/test_apestrip.py, 46 total): v1-only preservation, multi-value migration, permission preservation, read-only-dir error path, the three trailer shapes, and first coverage of themainexecution loop (happy path, vanished tag, error exit).
Bugfix release from the 2026-07-01 audit (roadmap items M7 to M9); no new features.
- Fix: custom mode no longer fails a whole album over one rsgain-unsupported file.
rsgain customrejects its entire file list when one entry is unsupported (verified live: a raw ADTS.aacbonus track failed a 12-FLAC album under--target-lufswhile default easy mode sailed through). The custom branch now filters to the formats rsgain 3.6 can write (perrsgain --help), logs eachSKIP (rsgain-unsupported)file, and skips an album with nothing left. - Fix: dry-run honors
--skip-tagged. The preview printed "Would scan" over the unfiltered worklist while apply filtered it; on a mostly-tagged library it predicted hundreds of albums where apply would scan a handful. The scan set is now computed once, before the dry/apply split; the dry-run marks skipped rows and summarizes "Would scan X of Y". - Fix: nested album folders are no longer rescanned via rsgain easy's recursion.
rsgain easyscans a directory recursively, so a parent album dir with a loose track besideCD1/meant CD1 was scanned twice and, with--skip-tagged, rewritten even when skipped. Such parents are excluded from easy-mode runs and reported (NESTED ALBUM (skipped): ... scan the subfolders or flatten); custom mode passes explicit direct files and is unaffected. The fuller option (scanning the parent's direct files as their own unit via custom mode) is deliberately not taken yet; flagged as an open decision. - Tests extended (
tests/test_replaygain.py): supported-set filtering and argv exclusion, nested-parent detection (including the sibling-string-prefix trap), and firstmaincoverage via dry-run runs; the test file now importsAUDIO_EXTENSIONSfromlattice.configinstead of carrying a copy.
Bugfix release from the 2026-07-01 audit (roadmap items M10, M11); no new features.
- Fix: one bad file no longer crashes the whole run.
tags.savesat outside the try in a function documented "never raises", so a read-only file or full disk killed the walk with no log line and no SUMMARY.rerate_filenow returns(changes, error);mainlogs anERRline per failure, counts errors into the SUMMARY, and exits 1 when any occurred. - Documented decision (M11): the remap stays byte-only, with no POPM email filter. Safe in a DeaDBeeF/foobar-only library; the docstring now states the assumption prominently and points at the dry-run output (which prints each frame's email) for libraries touched by other taggers. Revisit with an email gate only if a foreign tagger actually shows up.
- Tests extended (
tests/test_rerate.py): the error result on a read-only file, and firstmaincoverage (error accounting, exit codes).
Bugfix release from the 2026-07-01 audit (roadmap items M16, M17); no new features. Exit-code contract change: main now returns 1 when any file failed to write (known callers: genre_tidy.py apply, which wants exactly this, and manual use).
- Fix: failures are visible to callers. Per-file failure messages go to stderr (they were buried in stdout), and a run where files failed exits nonzero, so
genre_tidy.pycan count the album as an error instead of a successful retag. - Fix: unwritable audio in a mixed album is named. Files retag cannot write (
.wav,.aac,.alac,.ape,.wv,.aiff) were silently skipped; each now logsskip (unsupported): <name>, and the "No valid audio files found" note mentions that subdirectories are not descended. - Tests extended (
tests/test_retag.py): stderr routing, nonzero exit with a read-only file, full-success exit 0, and the mixed-album skip line.
Bugfix release from the 2026-07-01 audit (roadmap items M14 to M17); no new features.
- Fix: artists whose name starts with
#are data, not comments.#1 Dadused to be discarded by the map parser, could never be enforced, and was re-appended as a "new" artist on every rebuild. The comment rule is now explicit (#+ space, dash, or end of line); generated comments always match it, so a#-leading artist survives the round-trip. The rule is documented in the map header. - Fix: EXCLUDED (Various Artists) entries no longer re-append on every rebuild. They intentionally have no data row, so the "already present" check could never see them and every
buildappended a fresh dated marker plus a duplicate EXCLUDED comment; they are now filtered from the new-artist diff and the "No new artists" path is reachable. - Fix: a failed retag counts as an error, not a retag.
applycounted an album intoretaggedbefore invoking retag and retag always exited 0; with retag v1.1.1's exit code, a nonzero run now incrementserrors(with the captured stderr logged) andretaggedonly counts successes. - Fix: albums retag cannot write are skipped with a reason. A
.wav-only album with a stray genre was "retagged" forever (retag skipped every file, exited 0, nothing converged).applynow checks the album's extensions against retag's writable set (imported fromretag.py, so the two cannot drift), logsUNSUPPORTED FORMAT (skipped)once, and counts it under a newunsupportedstat. - Tests extended (
tests/test_genre_tidy.py): the comment rule and round-trip, rebuild no-op with a VA comp, and firstcmd_build/cmd_applycoverage (success, failure-counts-as-error, unsupported-format convergence) with the real retag subprocess.
Bugfix release from the 2026-07-01 audit (roadmap items M12, M13); no new features.
- Fix: a rejected album move no longer triggers its sidecar bookkeeping. When an album was skipped (
DEST EXISTS/collision), its artist-level sidecars (cover art, .nfo) were still relocated to the target genre, stranding the un-moved album without its artwork, and the source dir was still queued for pruning. Bookkeeping is now gated on the move actually being planned. - Fix:
--revertruns through the Runner. Reverts moved as much as an apply but bypassed the context manager: no audit trail, no replay manifest (the.revert.tsvwas named but never written), and a dry-run revert always predictedpruned=0because nothing fed the virtual-removed set. Each restore now goes throughdo_move(kindrevert), so the manifest, stats, and dry-run prune prediction all come for free; the Runner also models virtual creations, so a dry-run revert sees restored albums as children of their original parents and predicts exactly the prunes a real revert performs. - Tests extended (
tests/test_genre_foldermap.py): rejected-move bookkeeping, the replayable.revert.tsv, dry-vs-real revert prune parity, and dry-run-revert-touches-nothing.
Bugfix release from the 2026-07-01 audit (roadmap items M18 to M20); no new features.
- Fix: multi-valued tags keep all their values through the typographic fold. The tag pass read only the first value and wrote back a singleton, so a FLAC with
artist=["Artist A’s Band", "Artist B"]lost the co-artist whenever the first value needed folding. Every value is now folded and written back as the full list. The authority restamp deliberately still collapses to the single survivor name (that is its semantic), now stated in a comment. - Fix: dry-run models creations, not just removals. Collision and rename decisions consulted the raw disk, so previews diverged from apply in several shapes: a three-way merge where both sources carry the same extra track previewed two plain moves where apply performs one move plus an
AUDIO COLLISION; two siblings folding to the same rendered name previewed two renames where apply performs one. The Run now tracks virtual creations (mapping each virtual destination to the real path holding its bytes, so size/kind checks still read real data), every existence/size check in the merge and rename paths goes through the virtual-aware views, and Pass 2/3 walks skip folders an earlier pass virtually merged away. Dry-run stats now match apply on all the audited fixtures; the residual limitation (contents of virtually-moved folders are not modeled recursively) is noted in the docstring. - Docs: the module docstring's stale v1.2.0 tag-pass text is gone. Two passes were both numbered "4." and the survivor described the pass as MP3-only and merged-folders-scoped, contradicting shipped v1.3.0 behavior; one accurate pass description remains, and the misleading "Pass 3" comment on the tag-target hook (which also fires for Pass 1/2 survivor renames) is corrected.
- Tests extended (
tests/test_cleaner.py, 72 total): multi-value fold and authority collapse, dry-vs-apply parity for the three-way merge and same-render siblings, and virtually-removed folders being skipped by the group scan.
Bugfix release for the TUI, from the 2026-07-01 audit (roadmap items H6 and H7); no new features.
- Fix: a mode error no longer kills the TUI with the terminal stuck in curses mode.
_run_with_capturehad no exception boundary, so a mode failure (say, an unwritable output path after a completed scan) escaped as a raw traceback, lost the captured results, and left the screen in curses mode because the in-mode progress bar had started a screen nothing tore down. Mode exceptions are now caught and paged under an[Error]heading with the full traceback plus whatever output was captured; Ctrl-C pages a[Cancelled]notice the same way. The curses screen is explicitly ended and the terminal reset before paging, and_TUIPbar.close()now ends the screen it started, so well-behaved modes hand back a sane terminal too. - Fix: a plain-text fallback session no longer routes progress to the curses bar.
interactive_menusetutils.IN_TUIunconditionally, so a session that had already degraded to the typed-input menu (curses missing, or stdin not a TTY) still handed progress to_TUIPbar, which could crash on the missing module or hijack the screen mid-session.IN_TUInow tracks_USE_CURSES, re-evaluated every menu pass. - Fix: a curses init failure no longer reads as "Quit". On capability-poor terminals (
TERM=vt100, dumb terminals)curs_set/color setup raisedcurses.error, which the menu treated as the user choosing Quit: the TUI silently exited 0 even though the text fallback works. Cosmetic capabilities (colors, cursor visibility) are now non-fatal, so such terminals get a monochrome TUI; a realcurses.wrapperfailure flips the session to the text fallback menu instead of exiting. - New tests in
tests/test_tui.pypin all three behaviors (paged tracebacks, fallback sessions leavingIN_TUIunset, the fallback sentinel re-entering the menu loop).
Bugfix release from the 2026-07-01 audit (roadmap items H1, H2, H3); no new features.
- Fix:
feat.detection no longer matches inside words. The guest-credit regex used a zero-width\s*boundary, so theftending "Left", "Swift", or "Croft" read as a feat marker:--normalize-tagsunder a merged/renamed artist folder rewrote a clean "Left Boy" tag to "Left Boy feat. Boy", and every further pass appended another "feat. Boy" (non-idempotent corruption). The marker now requires a real token boundary. Also fixed in the same change: a parenthesised credit like "A (feat. B)" no longer leaves the stray closing paren on the guest. - Fix:
.wmacollisions are treated as audio..wmawas inTAGGABLE_EXTbut missing fromAUDIO_EXT, so a differing-size.wmacollision during a merge fell into the non-audio branch and the source copy was deleted, violating the "audio is never overwritten or deleted" guarantee. It now survives as a.from-fragment.wmacopy, and--normalize-filenamesreaches.wmatracks.AUDIO_EXTis now documented against the package'sAUDIO_EXTENSIONS(.mp4stays deliberately excluded as ambiguous with video). - Fix: dry-run now predicts the survivor rename. The rename collision guard checked only the disk, where the merged-away source folder still exists during a dry-run, so the flagship scenario (canonical
Drive‐By Truckerswith a U+2010 hyphen absorbingDrive-By Truckers) previewedRETAIN NAMEwhile apply performed the rename, and the tag pass preview consequently missed the authority restamps. The guard now consults the run's virtual removals, so dry-run and apply report identical stats and tag targets. - Tests extended (
tests/test_cleaner.py): mid-wordftcases, paren stripping, feat idempotency,.wmacollision and rename, and a dry-vs-apply parity check on the survivor-rename fixture.
Bugfix release from the 2026-07-01 audit (roadmap item H4); no new features.
- Fix: multi-disc album subfolders no longer poison the genre vocabulary. The scanner emits one record per audio-bearing directory, so
Artist/Album/CD1in a flat library arrived as a depth-3 record and classified as "organized", which put the artist's name into the genre vocabulary, turned the placement gate on, and flagged every ordinary strayUNKNOWN GENRE: one disc folder broke the whole flat-to-genre conversion, and the disc album itself was never filed. Disc subfolders (CD1,Disc 2,disk_3,DVD 1,Side 1,Vinyl 2, ...) now collapse to their parent album, which moves as one unit with the discs inside; CD1+CD2 records dedupe to a single move, and discs whose genre tags disagree keep the first and flag aDISC GENRE MISMATCHissue instead of planning two moves of one folder. - Fix: organized disc albums no longer spam
TOO DEEP. A legitGenre/Artist/Album/CD1is recognized as that album (in place, no issue) instead of producing perpetual wrong-root noise. - Fix: inbox content can never read as "organized".
Unfiltered/Artist/Album/CD1strips to the album and is filed like any stray; anything deeper or stranger under the inbox is flaggedSTAGED TOO DEEP (left in inbox)for manual review rather than being silently treated as an organized album stuck in the inbox forever. - Tests extended (
tests/test_genre_foldermap.py): disc classification (flat, organized, staged, and an album genuinely named like a disc), one-unit end-to-end move with the gate off, vocabulary hygiene, disc genre disagreement, and the staged-deep flag.
Bugfix release from the 2026-07-01 audit (roadmap item H5); no new features. retag.py is unchanged.
- Fix: a slash canonical no longer causes a permanent retag loop.
applyused to split"Emo / Orgcore"on/into two genre values; retag wrote them as a multi-value tag that never read back equal to the map (lattice reads only the first Vorbis genre field; MP3's v2.3 save slash-joins without spaces), so everyapplyre-ran retag on the same albums forever: files rewritten, APEv2 stripped, mtimes churned, dry-run never converging. The canonical is now passed verbatim as one genre value, so the tag written is exactly the string the compliance check reads back and the run converges. This treats a slash genre as one genre (which is what DeaDBeeF/foobar2000 display anyway); nothing in the shippedartist_genre_defaults.tsvused a slash genre, so no live behavior changes. - Tests updated (
tests/test_genre_tidy.py): the single-argument invocation is pinned, and a new end-to-end check writes the slash canonical throughretag.apply_genreson FLAC and MP3 fixtures and asserts the re-read tag is compliant (a second apply would plan zero retags).
New feature (no package change): a staging inbox, so albums dumped outside the organized tree can be filed without being mistaken for a genre.
- The problem. A tagger such as MusicBrainz Picard drops fresh
Artist/Albumfolders into a holding area to keep them out of the organized root. If that holding folder lives at the library root (e.g.Music/Unfiltered/), the old depth logic readUnfiltered/Artist/Albumas an already-organized album under a genre literally named "Unfiltered" and refused to move it out. - What it does. A staging folder name (default
Unfiltered) is stripped from the front of a path before classification, soUnfiltered/Artist/Albumclassifies as a flat stray and is filed into the real taxonomy at the root, exactly likeArtist/Album. The genre vocabulary gate still applies, so a staged album only lands in a genre the library already uses (or pass--allow-new-genre). - The inbox is kept. Each staged album's per-artist source folder is pruned once emptied, but the
Unfiltered/inbox itself is never treated as a source dir, so it survives the run for next time. - New
--staging DIRsets the inbox name (defaultUnfiltered); pass--staging ""to disable the behavior entirely. The wrong-rootTOO DEEPguard is unaffected. - Tests extended (
tests/test_genre_foldermap.py): classify stripping for album and loose shapes, the unchanged no-staging behavior, end-to-end filing into the taxonomy with the cover carried along and the inbox kept, and the vocabulary gate still applying to staged albums.
Extends the v1.2.0 tag work into a general, library-wide typographic normalizer covering names at every depth and the title/album/artist tags across all formats.
--normalize-tagsis now library-wide and multi-format. It walks every audio file, not just merged/renamed artist folders. Title and album get a pure typographic fold (CP1252 mojibake, curly quotes, and broken hyphens cleaned; the words never change, so the folder is never an authority for them). Artist and albumartist fold the same way everywhere, except under a merged or renamed artist folder, where they are still restamped to the surviving folder name (guest credits preserved). All formats are handled (MP3/FLAC/Ogg/Opus/m4a/WMA), each written in its native fields; ID3 stays v2.3 + refreshed v1. Only changed fields are touched, so a clean file is a no-op and the pass is idempotent.- Correct typography is preserved. The tag fold now matches the folder rename's philosophy: en/em dashes and the ellipsis are kept (
Selected Ambient Works 85–92keeps its en dash), and a CP1252-mojibake dash is repaired to the real dash rather than flattened to a hyphen. Genuinely broken hyphens (U+2010-2012/2015) are still folded to ASCII. --normalize-namesnow recurses to every folder depth (it was capped at two), so album folders under aGenre/Artist/Albumlibrary are reached.- New
--normalize-filenamesrenames audio track files the same way (extension kept verbatim). It is separate from--normalize-namesbecause renaming files is a distinct change (filenames are referenced by playlists and cue sheets); the two compose. - Behavior-change note.
--normalize-namesreaching all depths and--normalize-tagssweeping the whole library are both broader than v1.2.0. Both remain off by default and dry-run-previewable; the v1.2.0 single-folder behavior is a subset of the new one. - Tests extended (
tests/test_cleaner.py): multi-format writer (FLAC + MP3, case-insensitive Vorbis keys, authority vs plain fold, no-op, absent-field), library-wide folding with and without a merge, deep folder + filename renames with collision/illegal guards, dry-run fidelity, and a run-twice idempotency check.
New feature (no package change): --normalize-tags teaches cleaner.py to rewrite embedded artist/albumartist tags so they match the surviving folder name after a merge or rename.
- The problem. The merge passes consolidate variant artist folders (
Bonnie 'Prince' Billy/Bonnie Prince Billy/ a CP1252-mojibakeBonnie \x93Prince\x94 Billy) into one folder, but the tags inside still carry the old spellings. Players that show the artist tag, not the folder, still see three artists. - What it does. With
--normalize-tags, a fourth pass rewrites the MP3artist/albumartisttags under every merged or--normalize-names-renamed artist folder to the survivor's name, which is the naming authority. CP1252 mojibake and curly punctuation fold to straight ASCII; a trailing guest credit is preserved (... feat. Tim O'Brien). A file already correct is left untouched. - Artist level from
--layout. A new--layoutoption (default{artist}/{album}) tells the pass which path depth names an artist, so a genre or album folder is never restamped as one. On a genre-first library, pass--layout '{genre}/{artist}/{album}'. - MP3-only, same guarantees as the other companions. Non-MP3 audio is reported and left alone (its tags are already authoritative); writes are ID3v2.3 + refreshed ID3v1, matching
retag.py. Off by default,--dry-runpreviews every change, and the run is idempotent. Runapestrip.pyfirst if a stray APEv2 tag is in play (APEv2 overrides ID3 on the players that read it). - Tests extended (
tests/test_cleaner.py): the fold/feat helpers directly, plus merge restamping, dry-run fidelity, the clean-file no-op count, and the artist-depth gate.
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`).