Files
lintunes/CLAUDE.md
T
travandClaude Opus 5.5 ea7f57e38b v0.30.0: Cassette delivery — requested songs arrive in your library
- Sending: a friend's requests ∩ what I still offer is copied into my
  outbox (via a .part name Syncthing never sees); whatever they no longer
  request is deleted, so the outbox cleans itself up.
- Receiving: a finished file I cassetted is imported exactly like Add to
  Library — organized into Artist/Album, tags read, date added now, none
  of their listening history — and a song wanted only for a followed
  playlist lands in the cache. Then requests.json is rewritten without
  what arrived and without what they stopped offering.
- Safe to repeat: Syncthing temp files ignored, Syncthing asked whether a
  file is whole, and an exact match already in my library is never
  imported twice.
- Folders are watched (re-armed after each rename), with a slow fallback.
- file_importer split into stage_file (off the GUI thread) and
  track_from_file (date added = now).
- pytest --syncthing now runs a real invite → share → request → deliver →
  cleanup between two Syncthing instances.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 21:32:25 -07:00

611 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 live on the kanban board at
`~/Documents/projtracker/projects/lintunes/` (use the `tasks` skill); the old
`TASKS.md`/`tasks-done.md` are archived there — don't recreate them.
## Commands
```sh
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:
```sh
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:<pid>") 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/art_search.py`** — Download Album Art. Queries **both** the
iTunes Search API and Deezer's public album search (neither needs a key)
and merges them through `rank_candidates`: album match beats artist match,
so a right-artist, wrong-album hit never wins. Two sources because neither
catalog is complete: iTunes has **no copy at all** of Digable Planets'
*Reachin'*, which Deezer finds on the first query (Round 60). One source
failing is not an error while the other answers. Embedding, whether from
there or from Get Info, goes through `gui/art_ops.embed_artwork`: write,
`mpris.invalidate_artwork`, size refresh. Get Info's `ArtSquare` **never
stages bytes that don't decode** (`clipboard_image` checks every candidate
with `QImage.loadFromData`), because a clipboard can advertise `image/png`
and hand over nothing. Every paste attempt is logged at INFO
(`lintunes.gui.info_dialog`), so it shows up in the journal.
- **`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. **An AcoustID result's score
belongs to the *audio*, not to any one recording**: every recording linked to
it carries that score, mis-tags included, and a song whose title another
artist also used collects them (Round 52: trav's Dionne Farris "I Know" is
linked to Jay-Z's, Marisela's, David Essex's and New Atlantic's). What tells
a real link from a stray is `sources`, the submission count — 475 against 6
and three 1s — so the lookup asks for it and `_link_tier` sinks anything
under a tenth of the strongest link in that result. Hence the key order
*who*, then *which take*, then *which release*: stray tier, agreement with
the artist the file's own tags name (`artist_hint_for`), duration bucket,
hint overlap, release rank. Artist before duration is the point — Jay-Z's
take was 1.5 s closer to the file than Dionne's own, and that used to decide
it. `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/url_import.py`** — `File → Import from URL…` (Round 49): trav's
`song` shell helper (`yt-dlp --extract-audio --audio-format mp3 "$@"`) run
from inside the app. Those flags are passed **verbatim**; everything else on
the command line is plumbing: `-P <tempdir>`, `--print before_dl:LTSTART …`,
`--print after_move:LTFILE %(filepath)s` and an `LTPROG` progress template,
so output is read from markers rather than scraped. yt-dlp is a *runtime
CLI tool* (`shutil.which`, the fpcalc pattern), never a pip dep, and needs
ffmpeg for the mp3. **No `-o`**: yt-dlp's default `Title [id].mp3` is what
`filename_tags` parses. `UrlImportWorker` (daemon thread, `ExportWorker`'s
shape, shares the status-bar progress widgets and so `_busy_worker()`)
downloads into a private `mkdtemp`, emits `downloaded(path)` per song, and
the GUI imports each one *as it lands*, through the normal `import_files`
(a copy into the organized tree) and straight into the Identify queue, so
song 1's dialog can be up while song 5 downloads. The GUI deletes the temp
dir only on `finished`/`failed`, which are queued after every `downloaded`.
`resolve_target` is the rule for "and add to current playlist?": above the
selected song in the shown playlist, else the end of the *playing*
playlist, else the end of the shown one, else the checkbox is grayed.
Smart/folder/system playlists are never targets. A batch keeps the link's
order (`position + songs already inserted`). With no AcoustID key,
`TrackIdentifier` proposes from the filename alone and never touches fpcalc
or the network. Only a URL import reaches that path, since the menu route
still insists on setup first. YouTube's "Sign in to confirm you're not a
bot" is about the *network* (`song` hits it too), and yt-dlp's cure is the
browser's cookies: a run that downloads **nothing** for that reason is
retried once with `--cookies-from-browser` (`default_cookies_browser`,
Firefox first), and a browser that worked is saved as
`ytdlp_cookies_browser` in **`config.json`**, not preferences — which
browser holds a YouTube login is this machine's business (Round 59).
- **`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`, which **ignores
auto-repeat**: on Wayland repeats are generated client-side until the
compositor delivers the release, and a busy gnome-shell turned one held
arrow into 1,676 track skips (Round 56). Every list sent over D-Bus must be
a typed `QDBusArgument` (`mpris.string_array`) — a plain Python list goes
out as `av`, which GDBus rejects. That was `xesam:artist`, and the error
flood it caused is what kept gnome-shell busy.
- **`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. Its menu item ("Sync Playlist to
Rabbit (Auxio)") was **retired in Round 51**, once andTunes could play what
it syncs; the module stays because `find_device` 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, R1 measurements in `andtunes/DEVICE.md`, tasks tagged `andtunes` on the board). 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**.
**The device's filesystem is case-insensitive**, so "RJD2" and "Rjd2" are one
folder there and one artist here: since Round 54 the app's `Library.group()`
keys *both* `albumsByKey` and `artistsByName` case-folded (and fetches the
artist before the album block, holding it — the raw-case lookup afterwards is
what NPE'd on the second track of a two-spelling album and blanked the whole
library), and `plan_andtunes_sync` folds both the collision rule and the
device diff, which had been re-copying every such file over MTP every sync.
`plan.stale` still carries the device's own spelling — that is what
`_delete_stale` unlinks by.
**A gvfs-MTP mount can hold a *phantom* directory** — one it lists happily
while the device has no such folder — and every write into it fails `EIO`
forever, because `mkdir(exist_ok=True)` sees the phantom and does nothing.
Only remounting clears it (`gio mount -u mtp://…` then `gio mount`). Two
rules came out of it (Round 55): a copy that raises `OSError` costs **that
song**, not the sync (it lands in the `unwritable` summary list), and the
song is then **left out of the m3u and `library.json`** — a manifest naming
a file that isn't there is worse than a short one, because the app skips to
the next track and the user sees the wrong song play. **Planning is a worker**
too (`AndTunesPlanWorker`): `plan_andtunes_sync` is pure, but it walks every
file on the device, and over MTP that froze the window long enough for GNOME
to offer to kill LinTunes mid-sync. Anything after the last album
(`_write_index`, buttons, pruning) must keep emitting progress — it was
minutes of silence with the line stuck on `Album art 501/501`, and
`_write_index` reads `Art/` in **one listing**, never a stat per album.
Since Round 50 the app exists: `andtunes/app/` is plain Java against the
Android framework, built by `andtunes/build.py` (aapt2 → javac → R8 →
zipalign → apksigner) — **no Gradle, no Kotlin**, because platform 33 +
build-tools 34 were already on disk and trav didn't want a 1 GB download
over cell for tools that make the same APK. `--ship` copies it to
`lintunes/android/andTunes.apk` with its version in `andTunes.json`;
**re-run `--ship` whenever the app changes**, since the committed APK is
what the self-updater carries and what `install.py` installs.
`andtunes/andtunes.keystore` is committed on purpose (both machines must
sign identically or `adb install -r` refuses the update). `install.py` is
Connections → Install andTunes on Rabbit…: adb as a runtime tool, then the
all-files + notification grants (a refused grant is not a failed install —
the app's first-run screen covers it), or an MTP copy to `Download/` with
no adb. Sync copies `buttons/*.png` (rendered by
`andtunes/tools/make_buttons.py`) into the device's `Buttons/` **only
where the file is missing** — those names are a contract with the app's
`Ui.button()`, which prefers the device copy. `proguard.pro` keeps no
debug attributes because R8 8.2 NPEs on javac 21's.
Round 51 closed the loop. The planner runs every track through
`export/web_support.conversion_for` (Android decodes the browser set), so
FairPlay is refused and ALAC/AIFF land as `.flac` — converted once into
`$XDG_CACHE_HOME/lintunes/andtunes-flac/<track id>.flac` and size-diffed
from there on, so a converted song isn't re-copied every sync. And the app
keeps `plays/andtunes-<install id>.json` in the play-journal shape, counted
on a natural finish like `Player`; `plays.bring_back` folds it into
`<data_dir>/plays/` with `play_journal.merge_totals` (per-track max — that
file has two homes and both desktops may bring it back) **before** anything
is copied, writing nothing when nothing moved. `PlayJournal.load` then
treats the R1 as one more machine.
**A wedged gvfs-MTP mount blocks in uninterruptible FUSE waits**: `timeout`
can't kill a process stuck on it, and `find_device()` — called on the GUI
thread every time the Connections menu opens, and by the GUI tests —
freezes with it. USB re-enumeration (lock/unlock with `mtp,adb`, an
`adb install`) is what wedged it in Round 51. Recovery: `kill` the
`gvfsd-mtp` process, then `gio mount mtp://<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/cassette/`** — friend library sharing (Round 63+; the spec is
`Cassette friend library.md`). LinTunes never talks to a friend: it drives
the **local** Syncthing's REST API (`syncthing_api.py`, key/address from
Syncthing's `config.xml`, overridable in `config.json`) and Syncthing moves
the bytes. Every failure is a `SyncthingError(step, detail, kind)`, because
the UI promises never to fail silently. One of trav's machines is the
**host** (`host.py`: `config.json` flag + a synced `preferences.json` record
naming it); everything lives in the machine-local
`~/.local/share/lintunes/cassette/` (`state.json` + `friends/<token>/
{out,in,cache}`), never the synced data dir — Syncthing folders must not
nest. A friendship is two folders, `lintunes-cassette-<token>-a` (inviter →
invitee) and `-b`, each send-only for its writer and receive-only for the
reader. **Syncthing hides an unknown device's folders** (verified on 1.30: a
stranger is only a pending *device*), so while an invite is open the
inviter's `CassetteService` *probes* a new knock — adds it with nothing
shared, reads which folder it offers, completes on the right `-b` token or
removes and remembers it (`state.rejected`). With no open invite nothing is
probed and knocks stay pending, untouched. The paste side can't tell "their
computer is off" from "their LinTunes is closed" (Syncthing briefly reports a
stranger's connection as up), so it shows one combined status.
`scripts/cassette_pair.py` starts throwaway Syncthings on loopback (no
discovery there, hence `CassetteService(addresses_for=…)`); tests marked
`syncthing` run a real handshake with `pytest --syncthing`. The service
starts from `run_gui`, never from constructing `MainWindow` — tests build
windows by the dozen and must not reach a real Syncthing.
**Friend mode** (`gui/friend_mode.py`, entered from the ▾ beside Library)
shows a friend's `library.json` through a second `LibraryView` over
`cassette/friend_library.FriendSource` — the slice of `LibraryManager` that
view reads, read-only. Their track ids are *theirs*: the table is
`set_read_only(True)` (no play, no drag, no copy into my playlists, only
"lookup on youtube"), and the cassette column is `track_table.CASSETTE_FIELD`
(registered in `COLUMN_MAP`, deliberately not `ALL_COLUMNS`), live only
where `cassette_hooks` is set. Nothing is written until Save and Close
(`service.save_requests` → `requests.json`); any other exit with changes
asks "Save changes?". `cassette/matching.py` is best effort (exact vs
fuzzy); `cassette/space.py` judges the music disk (red) and Syncthing's
`minDiskFree` floor on the cassette disk (orange) separately.
**Delivery** (`cassette/delivery.py` + `gui/cassette_delivery.py`) is
rebuilt from the folders on every pass, both directions: sender = their
`requests.json` ∩ what I offer *now* → `outbox/<id> ~ <name>` (copied to a
`.part` the `.stignore` hides, then renamed), and anything no longer
requested is deleted — a request vanishing *is* the delivery receipt.
Receiver: planning on the GUI thread (it reads the library), copying on
`DeliveryWorker`'s thread (`file_importer.stage_file` — the import split so
the copy is off the GUI thread), `manager.add_track` back on the GUI thread
(`track_from_file`: date added now, no listening history). An exact match
already in my library is never imported twice, which is what makes a crash
between import and requests-rewrite harmless; a missing `library.json`
prunes nothing.
- **`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).