# 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/.json` per playlist, and one `plays/.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/.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`). The bar's second row (`transport.HeaderRow`, placed by hand) holds three things owned elsewhere: the sidebar's Library button (`SidebarPanel.header`, kept edge to edge with the playlist list — `list_area`, scrollbar included — via `track_split`), the shown view's `header` (search box, playlist name — mounted on the content stack's `currentChanged`, returned to its view when it goes), and the seek row, exactly under the now-playing panel. Everything in it stands on one floor (`HeaderRow.BOTTOM` above the lists): boxes by their bottom edge, text by its baseline. The row of boxes above floats — equal space before, between and after the controls, with the panel a fixed share of the width. Since the Library button left the sidebar, `MainWindow` must hand it the first focus, or the playlist tree takes it and opens its first playlist at startup. `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 `/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 (`/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 `, `--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 `/.Trash-` 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//gvfs`), not mass storage — so plain file I/O, but never copystat and never trust mtimes (diff by name+size). Sync owns exactly `Music//` 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// /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//` 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**. A fully ticked *folder* is stored as `device_sync.folder_ids` and follows the folder — playlists added to it later sync too (`device_sync_dialog.resolve_sync_playlists` is what both the dialog and the sync read). **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. The art pass is held to the same rule (Round 70): it compares each cover against `plan.art_sizes`, the sizes the planner's scan already read, never a stat per album, and `art.album_art_jpeg` caches "this album has no art" as a `.none` marker, so an unchanged library costs the art pass no device I/O and no mutagen opens. 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/.flac` and size-diffed from there on, so a converted song isn't re-copied every sync. And the app keeps `plays/andtunes-.json` in the play-journal shape, counted on a natural finish like `Player`; `plays.bring_back` folds it into `/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:///`. - **`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 `