File → Export Playlist…, and the same item on a playlist's right-click
menu. A sibling of device_sync: pure plan_export() first, then a worker
on a daemon thread sharing the status-bar progress widgets. The manifest
(index.html / .m3u) is written last, so an interrupted export never
leaves a page naming files that aren't there.
Two destinations. A folder gives the audio under "Artist - Title" names
plus an extended .m3u. A web mix gives a self-contained static site
reproducing the hand-made yearly mixes, with a dialog for title,
description and hero image.
audio.js and jQuery are gone. The old pages shipped ~293 KB: a build of
audio.js whose upstream hasn't moved since 2012, plus jQuery 3.2.1
(CVE-2019-11358, CVE-2020-11022, CVE-2020-11023 — unexploitable on a
static page, since nothing untrusted reaches a jQuery HTML sink, but
dead weight regardless). audio.js never used jQuery; jQuery was there
for ~25 lines of tracklist glue. Both are replaced by a dependency-free
player.js plus a player.css transcribed from the customized audio.js
skin, so the page looks identical — same 250px #c7b563 bar, same
player-graphics.gif (byte-identical: it's an animated GIF whose loading
frame is a spinner), same shortcuts. ~293 KB → ~6 KB. The Flash
fallback went too; audio.js gated it on !canPlayType("audio/mpeg;"),
unreachable since ~2010.
Conversion happens only when a browser genuinely can't decode a file,
never because of bitrate — a 320 kbps MP3 is copied verbatim. Everything
that does convert targets FLAC, so a conversion cannot cost a bit; a
test asserts the exported FLAC's decoded PCM hashes identical to the
ALAC source. Against the real library that's 105 Apple Lossless and 8
AIFF out of 21,382 tracks. DRM'd tracks are reported in a confirmation
dialog, never silently dropped and never attempted. ffmpeg is the CLI
binary here, not Qt's ffmpeg backend, so it's detected at runtime.
Verified in Chromium 151 and Firefox 153 over CDP/Marionette: every
exported format decodes including the converted FLAC, the player builds,
click / space / arrows / scrubber-seek / autoplay-next all work, and the
network log shows no request for jquery, audio.min.js or any .swf.
mix-example/ removed; the template reproduces it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
208 lines
13 KiB
Markdown
208 lines
13 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`, and one
|
|
`playlists/<persistent_id>.json` per playlist. 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.
|
|
|
|
- **`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 only; smart and system playlists are
|
|
currently skipped. 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.
|
|
|
|
- **`lintunes/mpris.py`** — registers `org.mpris.MediaPlayer2.lintunes` over D-Bus
|
|
so the desktop's media keys / now-playing popup control playback. Spacebar and
|
|
arrow keys are handled locally via `MainWindow.eventFilter`.
|
|
|
|
- **`lintunes/trash.py`** — freedesktop.org Trash spec 1.0, hand-rolled (no new
|
|
dep). The trash is **per-filesystem**: music usually lives on a mounted volume,
|
|
so the file belongs in `<topdir>/.Trash-<uid>` with a *topdir-relative*,
|
|
percent-encoded `Path=`, not in `~/.local/share/Trash` (which would be a
|
|
cross-device copy the file manager can't "Restore"). The `.trashinfo` is
|
|
created with `O_EXCL` **first** to claim the name atomically, then the file is
|
|
renamed in; a failed rename unlinks the info file so there's never a
|
|
half-trashed pair. Raises `TrashError` without touching the file, so a caller
|
|
can treat failure as "not deleted". Only `LibraryManager` calls it.
|
|
|
|
- **`lintunes/device_sync.py`** — one-way playlist sync to the Rabbit R1 (Device
|
|
menu). The Rabbit mounts via **MTP/gvfs** (a FUSE path under
|
|
`/run/user/<uid>/gvfs`), not mass storage — so plain file I/O, but never
|
|
copystat and never trust mtimes (diff by name+size). Sync owns exactly
|
|
`Music/<Playlist Name>/` on the device (creates/overwrites/deletes there,
|
|
plus an Auxio-importable `.m3u`); it never deletes outside that folder and
|
|
only ever *reads* local library files.
|
|
|
|
- **`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/`). **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. Registered in `setup.py` via `package_data`; loaded with
|
|
`importlib.resources` so an installed copy finds it.
|
|
|
|
- **`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
|
|
|
|
- **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).
|
|
- **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.
|
|
- `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).
|