A single Right-arrow press skipped 1,676 tracks in 81 s and hard-froze the desktop. On Wayland key repeat is generated by the client until the compositor delivers the release, and the key filter treated every repeat as a new press. Each skip sent gnome-shell an MPRIS Metadata whose xesam:artist was a plain Python list, which marshals as "av" instead of "as". gnome-shell logged an error for every one (50k/min) and reloaded the cover, and that load is what kept it from ever delivering the release. Space/Left/Right now act once per press and swallow the repeats. xesam:artist goes out through mpris.string_array, which build_properties_changed now shares for invalidated_properties. The new test round-trips the signal into GDBus, which is what gnome-shell validates with, and fails 'av' == 'as' against the old code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
540 lines
36 KiB
Markdown
540 lines
36 KiB
Markdown
# 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
|
||
|
||
```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/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.
|
||
|
||
- **`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, 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**.
|
||
**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/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).
|