Right-click → Identify Track… fingerprints the file with Chromaprint's fpcalc and asks AcoustID who it is, then proposes the tags it found. The year is the point. A 60s song kept getting stamped with the year its CD reissue came out, which sorts the library wrong, so every candidate carries the recording's *original* year — the earliest release across all of its release groups — including the candidate that proposes a later greatest-hits as the album. Ranking separately prefers a plain studio Album over EP/Single over anything wearing a Compilation or Live secondary type, so the default proposal is the real album as well. Nothing is written until accepted: the dialog shows each current value beside an editable proposed one, and a row starts checked only where the proposal actually differs from what the file already has, so an untouched field can never quietly blank a tag. Applying goes through edit_track_fields, which is what buys the tag write, the abort-if-it-fails rule, artist/album file relocation and one undo step without re-deriving any of them. A selection is a queue reviewed one track at a time; a failure is a status-bar message that moves on rather than ending the batch. fpcalc is detected at runtime like ffmpeg and the AcoustID key lives in preferences (so it syncs, and stays out of git) — the round adds no new dependency. Also fixes a pre-existing round 40 test that used the real tummult mount point as its stand-in for an unreachable path, so it failed whenever that drive was plugged in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NsiFHdyVg1UhBSxDJfTNRm
353 lines
23 KiB
Markdown
353 lines
23 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. `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/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/`; **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
|
|
|
|
- **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.
|
|
- `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).
|