Files
lintunes/CLAUDE.md
T
travandClaude Opus 5 bcba621f3f v0.17.1: a library to break
Round 47 was verified by loading trav's real 21,531-track library. Nothing was
written and nothing was at risk, but it's the wrong habit and he said so. The
alternative is better for development anyway: reproducible, in-repo, and full
of the awkward cases on purpose rather than by luck.

scripts/make_dev_library.py builds a complete synthetic library — data dir and
music tree — from ffmpeg sine waves in six containers, with art on some albums
and not others, every playlist type (regular, folder, live/non-live/
unsupported/limited+nested smart, system, empty, duplicates), a play journal
and a tombstone. ~4 MB, gitignored; the generator is the artifact worth
keeping, not the sine waves.

The edge cases are the point. A collision pair identical in artist/album/title/
number, slashes and colons in every name, a track with no artist, a multi-disc
release, a compilation whose album_artist differs, a dangling location, a
208-character title, and non-ASCII plus an emoji all the way out to the m3u
filename. CLAUDE.md now carries the rule: never develop against the real
library, and when a feature needs a shape the fixture lacks, add it here.

Using it found two bugs in it — a relative --out made every location resolve
against the wrong root, and four-minute uncompressed clips made it 62 MB.

andTunes is paused on a weak connection (the Android SDK is a ~1-1.5 GB
one-time download; after that --offline builds need nothing). andtunes/
README.md now carries everything needed to resume cold: measured device facts,
the dp trap, exact toolchain commands, locked design decisions, and the
library.json contract. JDK 21 is installed and ticked off.

Also answers the scroll wheel question: it reads as volume because the ROM's
key layout maps the wheel's KEY_UP/KEY_DOWN to KEYCODE_VOLUME_UP/DOWN, but a
focused activity sees key events first — so andTunes can claim it by consuming
both those and DPAD_UP/DOWN, in onKeyDown and onKeyUp. No system file touched,
no other app affected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wrTys5U1fLb4pBD2LKWzV
2026-09-10 00:49:26 -04:00

29 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this is

LinTunes is an iTunes-replacement music library manager and player for Linux, built with PyQt6 / Qt Multimedia. It imports an iTunes 12 library, stores the library as plain JSON (syncable via Syncthing), and reproduces the iTunes UI (left sidebar + right playlist view, column browser, customizable per-playlist columns). spec.md is the original design brief; TASKS.md is the live backlog and tasks-done.md records completed work.

Commands

pip install -e .                 # install deps (PyQt6, mutagen, numpy, requests)
python3 -m lintunes.main         # run the app straight from the checkout
python3 -m pytest                # run the whole test suite
python3 -m pytest tests/test_round9.py            # one test file
python3 -m pytest tests/test_round9.py::test_name # one test

The app needs a --data-dir; config persists to ~/.config/lintunes/config.json. One-time iTunes import:

lintunes --import-xml "iTunes Library.xml" \
         --music-root "/path/to/iTunes Media" \
         --data-dir /path/to/library-data --save-config

Runtime needs FFmpeg codecs for Qt Multimedia (qt6-qtmultimedia w/ ffmpeg). Tests run headless via the qapp fixture in tests/conftest.py (QT_QPA_PLATFORM=offscreen).

Architecture

Data flows in one direction through three layers: storage (JSON on disk) → model (Library/Track/Playlist dataclasses) → LibraryManager (mutations + persistence) → GUI (Qt widgets that read the manager and connect to its signals).

  • lintunes/main.py — CLI entry. --import-xml runs a headless import and exits; otherwise run_gui() resolves Syncthing conflicts, loads the library, builds Preferences/LibraryManager/LastFm/MainWindow, wires MPRIS, and installs the window as a global event filter (for media keys).

  • lintunes/models/ — pure dataclasses (track.py, playlist.py, library.py) with to_dict/from_dict round-tripping. PlaylistType is REGULAR | FOLDER | SMART | SYSTEM. Per-playlist PlaylistSettings holds visible columns / sort column / widths. Playlists are identified by an 8-char hex persistent_id (folder containment via parent_persistent_id).

  • lintunes/storage/json_storage.py — the library is multiple files in the data dir: library.json (all tracks), library_metadata.json, one playlists/<persistent_id>.json per playlist, and one plays/<machine-id>.json per machine. Writes are atomic (*.json.tmp → rename). storage/conflict_resolver.py merges Syncthing *.sync-conflict-* files on startup: play counts take the max, edited fields take the newest, playlist membership takes the union except where a removal was recorded (Round 44, see tombstones.py). Since Round 39 that union is anchor-based (merge_track_order): a track only one copy has is re-inserted after the nearest track both share, not appended at the tail, so a middle insert stays in the middle. Whose order wins is decided by Playlist.date_modified, not by the file's mtime, which moves for cosmetic reasons. Since Round 43 a stamped copy also beats an unstamped one (a stamp exists only once LinTunes recorded an edit, so that is real evidence); mtime is the fallback only when neither side has ever been edited. Two rules follow: a merge that changes nothing writes nothing (an unconditional write reset the kept file's mtime and biased that fallback a little more every round), and a merge whose result is a union neither copy had stamps date_modified — that content is newer than both, and saying so is what stops two machines trading the same tracks back and forth. library_manager._reconcile_playlist is the same decision on the live-reload path and must read the stamps too; it is reached only for playlists in _dirty_playlist_content (real content edits), never for one that is merely cosmetically dirty from a column drag. Since Round 42 every ConflictSummary carries a level (WARNING / CHANGE / INFO): a merge that only reconciled column widths, or a smart playlist whose rules are byte-identical on both sides, is INFO and must never read as an edit the user made. The dialog (gui/conflict_dialog.py) shows one level and above and opens at the highest level in the batch, so a routine merge never steals focus but a blank window is impossible either. Round 43 made the summaries say something actionable: which copy won and when it was edited, who wrote the copy Syncthing set aside (the 7-char device token in the conflict filename, named via sync_identity.py), and every re-inserted track by Artist — Title and position — six in the window, all of them in what-changed.txt at the top of the backup snapshot. Never label a copy "this machine" from which file holds the plain name: that is Syncthing's choice, and it sets the local copy aside as readily as a remote one.

  • lintunes/storage/play_journal.py — why play counts can't conflict. Since Round 38 library.json holds only a base count and each machine owns plays/<machine-id>.json with its own per-track totals; effective count = base + the sum of every journal. Only the owner ever writes its journal, so two machines never touch the same file — and finishing a track no longer rewrites 15 MB, which is what handed Syncthing a conflict once per song. Totals, not an append log, so there is no compaction step to double-count in. PlayJournal.load() must be handed a library whose tracks still carry base counts (the fresh load at startup, the disk copy inside reload_from_disk) — folding an already-folded library promotes the effective count to base. Mirror-image rule in json_storage.save_tracks(): it writes journal.base_fields(tid), never the Track's effective count. Those two places are the whole hazard; tests/test_round38.py pins both.

  • lintunes/library_manager.py — LibraryManager(QObject) owns the Library, is the single funnel for all mutations, and persists them debounced (3 s) with per-area dirty tracking (a play-count bump rewrites only library.json; a playlist edit rewrites only that playlist file). User edits go through undo_stack (Ctrl+Z); the internal _apply_*/_set_* helpers do the real mutation + dirty-mark + signal and are reused by undo/redo without recursing. Widgets react to its signals (playlists_changed, playlist_content_changed(pid), track_updated(id), track_fields_edited).

  • lintunes/player.py — Player(QObject) walks a queue through a swappable PlaybackSink. Player owns the queue, shuffle walk, per-track start/stop times and the play-count/scrobble bookkeeping; a sink owns only "make this file come out of something, and report where it's up to". LocalSink (the QMediaPlayer/QAudioOutput pipeline, with the QAudioBufferOutput PCM tee that feeds the visualizer) stays in player.py — the Player tests stub Qt Multimedia with patch.multiple(player_module, QMediaPlayer=..., …), so those names must resolve in this module. cast/sink.py::CastSink is the other implementation. set_sink() carries the current track, position and playing-state across a swap and deliberately does not re-emit track_changed (that would double-scrobble the same song). Player is also deliberately context-agnostic: playback context ("library" / "playlist:") is tracked in MainWindow, not the player.

  • lintunes/gui/ — main_window.py assembles a top TransportBar over a horizontal QSplitter (SidebarPanel | stacked LibraryView/PlaylistView). track_table.py is the shared track grid (drag/drop, copy/paste, drop indicator). playlist_ops.py::add_tracks_with_dup_check is the single funnel for every add-to-playlist path. theme.py applies the palette (highlight colors, UI scales) from prefs.

  • lintunes/importers/itunes_importer.py — parses the iTunes XML plist. Remaps Mac file:///Volumes/... paths to the local --music-root with case/Unicode-normalization fuzzy matching (macOS is case-insensitive + NFD vs ext4). Imports user playlists, folders and smart playlists (criteria parsed by smart.parse_itunes_smart; criteria it can't model are kept as a static snapshot — report.smart_unsupported). System playlists are skipped (report.skipped_system). Album art is read live from embedded ID3 tags (tagging.py), never stored in the library JSON.

  • lintunes/preferences.py — app settings in <data_dir>/preferences.json (rides the same Syncthing share). Preferences.set(key, value) saves and emits changed; MainWindow._on_prefs_changed re-applies theme/metrics live. Because it is synced, anything machine-specific belongs in config.py's config.json instead (see music_folder.py). now_playing_font has three states — None automatic (best available from theme.NOW_PLAYING_FALLBACKS), "" the app font, or an explicit family. Startup never prompts for it; the row in Preferences is the only place it's chosen.

  • lintunes/music_folder.py — where the organized tree lives on this machine. The library stores it three ways (models/library.py): music_folder absolute (what pre-0.10 code reads — kept forever as the shared floor between versions), music_folder_rel relative to the data dir (the portable one, same trick as Track.location), and music_folder_set_at, a stamp so a merge prefers the newest setting rather than the newest file. resolve() tries rel → legacy → the per-machine override in config.json (both music_folder_override and the older music_root that --music-root … --save-config writes — an imported library already knows where its music is on this machine, so don't ask), and every candidate must exist — the override is consulted last so a machine that once needed one isn't pinned to it forever. Two rules that bite: LibraryManager.set_music_folder must call mark_library_settings_dirty() (without it reload_from_disk reverts the change on the next sync tick), and organize_root() can return None, which every caller must handle rather than inventing a relative path — MainWindow._music_import_dir used to fall back to Path("Music"), which resolved against a working directory GNOME's dash doesn't set predictably.

  • lintunes/tombstones.py — why a deletion sticks. Two copies with no common ancestor cannot tell "A added this" from "B removed it", which is why every merge was a union — and why a song removed on one machine came back from the other forever. So removals are recorded: Playlist.track_events ({track id: [when, "add"|"remove"]}) and Library.deleted_tracks ({track id: when}, in library_metadata.json). A merge takes the newest event per track across both copies and applies it, so a removal beats a copy that merely still had the song and a later re-add beats the removal; a track with no event still merges as a union. Events are recorded in the same single funnels as everything else — _set_track_ids for playlists, _remove_tracks for the library — and pruned after RETENTION_DAYS (30) at the save boundary, so a machine offline longer than that can resurrect something. Three rules: track ids are never reused (_highest_track_id counts deletions, or a new track would be dropped on sight by the dead id's own tombstone), library_metadata.json is merged before library.json (it carries the record the library merge is filtered against — resolve_conflicts sorts for it), and a merge applying a deletion never touches a music file; it only drops the library entry, and reports at WARNING.

  • lintunes/sync_identity.py — turns a conflict filename's 7-char device token into a device name, by reading Syncthing's config.xml and deriving our own device ID from cert.pem (base32 of the SHA-256 of the DER cert). Stdlib only, cached, and every failure path returns None — a machine with no Syncthing must still merge, just without naming anyone.

  • lintunes/fingerprint.py — "Identify Track…": Chromaprint's fpcalc CLI (detected with shutil.which, the ffmpeg_available() pattern — a runtime tool, never a pip dep) fingerprints the file, and AcoustID's web API says what it is. Shaped like art_search.py: pure parse/rank helpers first (tested offline against canned JSON), then subprocess/network, then TrackIdentifier(QObject) on a daemon thread emitting a dict carrying either candidates or error. The year rule is the point of the feature: every candidate carries the recording's original year — the earliest release across all its release groups — even the candidate proposing a later compilation as the album, because a 60s song must not sort by the year its CD reissue came out. _releasegroup_sort_key separately ranks a plain studio Album above EP/Single above anything with a Compilation/Live secondary type, so the default proposal is the real album. gui/identify_dialog.py is passive (the AlbumArtDialog contract — nothing written until accepted); the caller applies result_fields() through LibraryManager.edit_track_fields, which is what buys tag writes, undo and artist/album file relocation for free. A row is pre-checked only where the proposal differs from the current value, so an unchecked field can never quietly blank a tag. The API key is the user's own, in preferences.acoustid (the Last.fm precedent — preferences.json syncs, so it never belongs in git). Audio under ~3 s has no fingerprint at all ("Empty fingerprint"), which is a reported failure, not a crash.

  • lintunes/filename_tags.py — the offline half of Identify Track, and the answer to its biggest limitation: AcoustID only knows music somebody submitted, so an underground/SoundCloud rip fingerprints perfectly and matches nothing (verified: "B. Clem — Zuuso" returns zero results from AcoustID, zero from a MusicBrainz text search, zero from iTunes). Its filename, though, says exactly what it is. Pure string work, no network, no Qt: strips yt-dlp's trailing id ([1025657891], -WC7gK2kgyTQ — an 11-char token is only treated as a YouTube id if it carries a digit, an underscore, or repeatedly flipping case, or "Underground" would be eaten), drops (Official Video)-style noise, splits Artist - Title on a spaced hyphen only (so "Jay-Z" and "350-440-DialTone" survive), collapses a doubled uploader, reads a leading 1-04, restores _s→'s, and falls back to the iTunes tree (<root>/Artist/Album/NN Title.ext), ignoring placeholder dirs like "Unknown Artist". fingerprint.candidate_from_filename wraps it as a candidate with source="filename", appended to every lookup and standing alone when there are no matches — the dialog then says where the guess came from instead of quoting a fabricated confidence. The same filename also feeds hint_for(track), which ranks real lookup results: token overlap (words worth double, bare numbers single — "alhambra" identifies a release, "1961" appears in every compilation spanning it), a coarse duration bucket off the recording lengths AcoustID returns (which is what separates a 150 s studio take from a 155 s live one), and a year named in the filename that predates anything the database knows. That last one is not a nicety: MusicBrainz's own first-release-date for "Ahmad Jamal's Alhambra" is 2002, because its three original 1961 pressings are in the database undated — so a second MusicBrainz call would return the same wrong year, and the file's own name is the only place 1961 exists.

  • lintunes/mpris.py — registers org.mpris.MediaPlayer2.lintunes over D-Bus so the desktop's media keys / now-playing popup control playback. Spacebar and arrow keys are handled locally via MainWindow.eventFilter.

  • lintunes/trash.py — freedesktop.org Trash spec 1.0, hand-rolled (no new dep). The trash is per-filesystem: music usually lives on a mounted volume, so the file belongs in <topdir>/.Trash-<uid> with a topdir-relative, percent-encoded Path=, not in ~/.local/share/Trash (which would be a cross-device copy the file manager can't "Restore"). The .trashinfo is created with O_EXCL first to claim the name atomically, then the file is renamed in; a failed rename unlinks the info file so there's never a half-trashed pair. Raises TrashError without touching the file, so a caller can treat failure as "not deleted". Only LibraryManager calls it.

  • lintunes/device_sync.py — one-way playlist sync to the Rabbit R1 (Device menu). The Rabbit mounts via MTP/gvfs (a FUSE path under /run/user/<uid>/gvfs), not mass storage — so plain file I/O, but never copystat and never trust mtimes (diff by name+size). Sync owns exactly Music/<Playlist Name>/ on the device (creates/overwrites/deletes there, plus an Auxio-importable .m3u); it never deletes outside that folder and only ever reads local library files. Since Round 47 this is the older of two syncs — kept on the menu as "Sync Playlist to Rabbit (Auxio)" until the andTunes app can play a song — and its sanitize_name / track_display / track_filename / build_m3u / CHUNK are imported by both export/exporter.py and andtunes/, so their signatures are load-bearing.

  • lintunes/andtunes/ — the desktop half of andTunes, a music player for the Rabbit R1 (Round 47; the app itself lives in andtunes/ at the repo root, board in andtunes/TASKS.md). The premise: the app never scans anything. Auxio re-reads Android's MediaStore on every launch, which is the "your songs will show up here" hang — but LinTunes already knows the artist, album and year of every file it just copied, so it writes them into Music/andTunes/library.json and the app parses one file instead of indexing a filesystem. layout.py is the on-device shape (Media/<Artist>/ <Album>/04 Song.mp3 — one copy per song however many playlists hold it — plus Art/, Playlists/, library.json); manifest.py is pure and Qt-free; art.py renders one 480 px JPEG per album (not per track) via QImage — safe off the GUI thread, unlike QPixmap — cached under $XDG_CACHE_HOME/lintunes/andtunes-art and invalidated by the audio file being newer, which is exactly what embedding new art does. sync.py is a third sibling of plan_export/ExportWorker: pure planner, then a daemon thread with KiB progress. Three rules that bite: planning must not render art (a mutagen open per album, and planning runs on the GUI thread — the worker does it, and those bytes are absent from the progress total); the index (m3us then library.json) is written last and after a cancel is rewritten to list only tracks whose files actually landed, so the app never opens a manifest with holes in it; and every delete goes through layout.assert_inside, which refuses anything not strictly inside the andTunes root. That guard is why the old Music/<Playlist>/ folders are structurally unreachable rather than merely un-referenced, and Buttons/ (the user's own menu artwork) and plays/ (written by the app) are skipped by the device scan entirely — sync reads those, it doesn't own them. Which playlists go on the device lives in preferences.json under device_sync (so it rides the Syncthing share and both machines agree); keeping it off the Playlist keeps it out of the conflict-merge machinery, and unticking is the only way a playlist comes off the device.

  • lintunes/export/ — File → Export Playlist… (also on a playlist's right-click menu). A sibling of device_sync, reusing its filename helpers and build_m3u: pure plan_export() first, then ExportWorker on a daemon thread. Two destinations — a folder (files as Artist - Title.ext plus an .m3u) or a web mix (index.html + audios/ + hero image, from templates/; no m3u — it's a folder you upload, not one you open in a player, so plan.m3u_name is "" there). The manifest is written last, so an interrupted export never leaves a page or m3u naming files that aren't there. web_support.py is the format gate, shaped like cast/support.py: deny-by-default on the suffix with the iTunes kind breaking the .m4a tie. Bitrate never triggers a conversion — only unplayability does — and every conversion targets FLAC, so it can't cost a bit; DRM'd tracks are reported, never attempted. ffmpeg is the CLI binary here (not Qt's ffmpeg backend), so it's detected at runtime and its absence is offered as "export without them". The templates ship a ~180-line dependency-free player.js/player.css that replaced audio.js + jQuery (abandoned since 2012; jQuery was only ever glue — audio.js never used it). player-graphics.gif must ship byte-identical: it's an animated GIF whose loading frame is a spinner, not a flat sprite sheet — which is why Round 41 recolors its pale glyphs to black with filter: brightness(0) in CSS rather than editing the file. The one user-chosen color (link/row hover backgrounds + the progress fill) travels as a :root { --accent } custom property declared in index.html, so player.css can read it while staying a verbatim shutil.copyfile — the page is still the only rendered template. exporter.normalize_accent is a hard gate, not politeness: the value lands raw inside a <style> block and string.Template escapes nothing. The accent is not persisted, so an untouched export still renders the original #8c764a. Templates are registered in setup.py via package_data; loaded with importlib.resources so an installed copy finds them.

  • lintunes/cast/ — Chromecast playback (Connections menu), using the media-receiver model: server.py runs a ThreadingHTTPServer on an ephemeral port for the life of a session and the device fetches the original file itself (bit-exact, no transcode). URLs carry an opaque random token, never a path, so traversal is structurally impossible; Range + HEAD are mandatory (the device seeks by re-requesting ranges and won't report a duration without them). A token resolves to an _Asset that is either a file on disk (audio) or an in-memory blob (album art, which lives in tags rather than as its own file); the two have separate eviction rings so a cover can't push out the previous track's audio. Art is passed as play_media(thumb=…), which pychromecast folds into metadata["images"] — that's what a TV paints full-screen. support.py is the format gate — ALAC, AIFF and protected AAC are refused and Player skips them with a status-bar message. discovery.py wraps CastBrowser; sink.py is the PlaybackSink; controller.py owns the session and its own SleepInhibitor. pychromecast is imported lazily, never at module scope, so the app still launches where the dep isn't installed yet. While casting there is no local PCM, so the visualizer panel doubles as the cast indicator: it shows the cast glyph instead of bars and a click there stops casting (the brightness cycle is suppressed — it means nothing with no bars). That is the only cast control in the transport bar. Position comes from a 500 ms poll of adjusted_current_time (only trusted while PLAYING — it creeps while paused).

Conventions & gotchas

  • The Rabbit R1's screen is not what the internet says. Measured: 480 × 640 px, physical density 320, override density 160 — so an app sees a 480 × 640 dp canvas on a 2.88" panel (~278 real ppi). One dp is about half its usual physical size, so andTunes doubles every stock value (rows ≥ 88 dp, text 28–32 sp). Don't "fix" it by changing the device density; trav has it where he wants it. Android 13 / API 33, arm64-v8a. The scroll wheel emits KEY_UP/KEY_DOWN (KEYCODE_DPAD_UP/DOWN).

  • Tests are organized as tests/test_roundN.py — each development round adds a new test_roundN.py alongside the topical files (test_models.py, test_itunes_importer.py, etc.). New feature work follows the same pattern.

  • Keep Player and track_table manager-free where they already are — cross-cutting data is injected via callbacks/signals (e.g. track_table takes a playlists_for_track callback rather than importing the manager).

  • Cosmetic table settings must not look like edits. The last column is stretch-sized, so Qt re-fires sectionResized for it on every viewport width change; track_table._on_section_resized ignores that section, or resizing the window would rewrite the open playlist's JSON (and hand Syncthing a conflict) purely for a width apply_settings overrides on load anyway. Round 42 is the same rule one level down: a live smart playlist's membership is never persisted (Playlist.has_derived_membership gates it in json_storage.save_playlist, which writes track_ids: [] + derived_membership: true) and recompute_smart_playlist passes touch=False to _set_track_ids so it marks nothing dirty and moves no timestamp. Membership is rebuilt from the criteria on every load (recompute_all_smart), so storing it only bought a conflict per song — one per finished track, on the same file, from both machines. The exceptions are live_update=False and unsupported criteria: their track_ids are the content (a snapshot), so they still persist and still count as edits.

  • Qt/Wayland gotchas (GNOME/Mutter): QDrag.setPixmap / setDragCursor / QCursor.pos() are unreliable during a drag — gui/drag_ghost.py paints its own child-widget overlay instead. QAudioOutput must not be constructed before a QMainWindow exists (Qt 6.10 deadlock). Some PyQt signal relays need explicit types/lambdas.

  • Music files are only touched deliberately: tag edits via tagging.py, and — since Round 18 — artist/album_artist/album edits relocate the file inside LibraryManager.organize_root() (<music_folder>/Music) to keep the tree organized iTunes-style (_maybe_move_file; undoable; files outside the root are never moved; the new path syncs cross-machine via the location newest-wins merge in conflict_resolver). Since Round 33 the only other path is an explicit user delete (LibraryManager.delete_tracks), which moves the file to the desktop trash via trash.py — never unlink, so it stays recoverable. Nothing else may move, rewrite or remove music files. The library JSON is the source of truth for everything else.

  • Versioning & self-update: __version__ in lintunes/__init__.py is the single source of truth (setup.py regex-reads it, never imports the package). Claude bumps minor for feature rounds and patch for fix-only rounds as part of each round's final commit; trav decides major bumps. The status-bar version button (gui/version_button.py + lintunes/updater.py) checks origin shortly after launch and every 4 h, and a click runs git pull --ff-only then re-execs the app — pushing master is effectively releasing to the other machines (a new pip dependency still needs a manual pip install -e . there). Every round ends with commit AND push (trav's standing request, 2026-07-03: both machines ride the bleeding edge, sync as often as possible) — so never leave master in a half-working state between commits you push.

  • Never develop or verify against trav's real library. It is 21,531 tracks of irreplaceable music and no amount of read-only care makes it the right thing to point a half-finished feature at. scripts/make_dev_library.py builds a synthetic one that has the same shapes — every playlist type (regular, folder, live/non-live/unsupported smart, system, empty, duplicates), six container formats, art on some albums and not others, a play journal, a tombstone, and a deliberate pile of naming edge cases (a collision pair differing only by id, an emoji album, a 208-character title, forbidden characters, a dangling location). Build it with python3 scripts/make_dev_library.py --out dev-library and run against it with --data-dir dev-library/data; it's gitignored, the generator isn't. When a feature needs a shape the fixture lacks, add it to the generator rather than reaching for the real library.

  • scripts/ holds one-off maintenance tools (audit_artwork.py, recover_artwork.py, clear_computed_ratings.py) run manually against a data dir; most default to dry-run and need --write to mutate files.

  • Not under version control until recently — the *~ files are editor backups (gitignored). data/ and itunes-test-library/ are gitignored (the user's real library + large import fixture).