Store track paths relative to the data dir for multi-machine sync
The library is going live as the canonical store, synced to a second machine via Syncthing. Track locations were stored absolute and only remapped at import, so on a second machine (where Syncthing mounts the folder at a different path) every location would break. - lintunes/paths.py: to_relative/to_absolute. Locations are stored relative to the data dir and resolved back on load, applied only at the json_storage boundary (save_tracks/load_library). Track.location stays absolute in memory, so the player, tagging, and art code are unchanged. The data dir and the music move together inside one synced tree, so paths resolve wherever it's mounted — no per-machine music_root config. Absolute paths in older library.json files still load (back-compat). - tests/test_round15.py: helper round-trips, back-compat, and a machine-2 scenario (save under root A, load the copied tree under root B). - TASKS.md/tasks-done.md: mark the data-dir move + real import done; log the benign exit-time Qt/FFmpeg teardown segfault. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
25
TASKS.md
25
TASKS.md
@ -11,13 +11,17 @@
|
|||||||
|
|
||||||
- [x] visualizer should have more discrete mode that's a light gray on light gray
|
- [x] visualizer should have more discrete mode that's a light gray on light gray
|
||||||
|
|
||||||
- [ ] we need to be able to make smart playlists. They should also be properly imported from itunes. We need all the fields that itunes 12 is able to work with when creating a smart playlist. This is a complicated feature! We also need to be able to edit the criteria of a smart playlist once it is created. Smart playlists should have a little Rotated Floral Heart Bullet (❧) to the left of their title in the playlist list on the left.
|
- [~] we need to be able to make smart playlists. They should also be properly imported from itunes. We need all the fields that itunes 12 is able to work with when creating a smart playlist. This is a complicated feature! We also need to be able to edit the criteria of a smart playlist once it is created. Smart playlists should have a little Rotated Floral Heart Bullet (❧) to the left of their title in the playlist list on the left. _(Round 14 — Phase 1 done: full iTunes import (incl. nested groups, which parse + evaluate) and a flat-rule editor. **Phase 2 TODO:** nested-group editing UI in the dialog — until then, imported nested playlists are shown read-only. See tasks-done.md.)_
|
||||||
|
|
||||||
- [x] the ability to not just copy/paste a song but also to ctrl-x cut a song from one space and ctrl-v paste it somewhere else. AND when pasting a song(s) it should paste above the currently selected track. That's where it pastes to, not to the end of the playlist.
|
- [x] the ability to not just copy/paste a song but also to ctrl-x cut a song from one space and ctrl-v paste it somewhere else. AND when pasting a song(s) it should paste above the currently selected track. That's where it pastes to, not to the end of the playlist.
|
||||||
- [x] if the user is dragging a track around and they drag above the top of the track list, the track list should scroll up. Same if they hover the track below the track list at the bottom it scrolls down. The track list should scroll in the direction the user is hovering the track. This helps the user move a track to a place in a playlist that isn't currently seen.
|
- [x] if the user is dragging a track around and they drag above the top of the track list, the track list should scroll up. Same if they hover the track below the track list at the bottom it scrolls down. The track list should scroll in the direction the user is hovering the track. This helps the user move a track to a place in a playlist that isn't currently seen.
|
||||||
|
|
||||||
- [ ] the app should be a little more agressive about comandeering the play/pause button from other media playing on the system. If I was playing a youtube video and that was associated with play/pause, but it's been hours since I played it, and I started playing music in lintunes, I would expect the play/pause button to pause the lintunes music when I hit it— not also start playing a youtube video, you know what I mean?
|
- [ ] the app should be a little more agressive about comandeering the play/pause button from other media playing on the system. If I was playing a youtube video and that was associated with play/pause, but it's been hours since I played it, and I started playing music in lintunes, I would expect the play/pause button to pause the lintunes music when I hit it— not also start playing a youtube video, you know what I mean?
|
||||||
|
|
||||||
|
- [ ] Minor: LinTunes segfaults during Qt-Multimedia/FFmpeg pipeline teardown on
|
||||||
|
app **exit** (fires after the GUI is gone — cosmetic, no data risk since writes
|
||||||
|
are atomic). Tidy the shutdown so the media objects are released cleanly.
|
||||||
|
|
||||||
|
|
||||||
## when we're ready to go live
|
## when we're ready to go live
|
||||||
|
|
||||||
@ -43,13 +47,22 @@
|
|||||||
via tagging.write_artwork). scripts/audit_artwork.py re-checks coverage.
|
via tagging.write_artwork). scripts/audit_artwork.py re-checks coverage.
|
||||||
- `.itc` decoder lives in lintunes/itc.py (tests/test_round9.py). -->
|
- `.itc` decoder lives in lintunes/itc.py (tests/test_round9.py). -->
|
||||||
|
|
||||||
- [ ] **Move data dir** to `/run/media/trav/tummult/music/lintunes/` once trav
|
- [x] **Move data dir** to `/run/media/trav/tummult/music/lintunes/` — done
|
||||||
confirms the real import looks right (re-run import or copy `./data`, update
|
2026-07-01 (fresh import written there, config saved with `--save-config`).
|
||||||
config with `--save-config`). Where the data dir is should be a setting in the preferences of
|
Track paths are now stored **relative to the data dir** (`lintunes/paths.py`,
|
||||||
|
applied at the `json_storage` boundary) so the Syncthing-shared library is
|
||||||
|
portable to the second machine regardless of its mount point. See tasks-done.md
|
||||||
|
Round 15.
|
||||||
|
|
||||||
- [ ] Run the real import on trav's library and eyeball the result (fixture in
|
- [ ] Make the data dir location a **preference** (currently only settable via
|
||||||
`./itunes-test-library/`).
|
`--data-dir` / `--save-config`).
|
||||||
|
|
||||||
|
- [x] Run the real import on trav's library — done 2026-07-01: 21,382 tracks,
|
||||||
|
462 playlists + 21 folders, 18 smart playlists (3 kept as snapshot), 5 missing
|
||||||
|
files, 1,324 case/unicode path fixes. Imported to the synced data dir; old
|
||||||
|
`./data` kept as a rollback backup until machine 2 is confirmed.
|
||||||
|
|
||||||
|
Artwork migration still to run against the live library (before relying on it):
|
||||||
1. `python3 scripts/audit_artwork.py --data-dir <data> --xml "<iTunes XML>"`
|
1. `python3 scripts/audit_artwork.py --data-dir <data> --xml "<iTunes XML>"`
|
||||||
(read-only) to re-confirm the counts on the freshly-imported library.
|
(read-only) to re-confirm the counts on the freshly-imported library.
|
||||||
2. `python3 scripts/recover_artwork.py --data-dir <data> --dry-run`, review,
|
2. `python3 scripts/recover_artwork.py --data-dir <data> --dry-run`, review,
|
||||||
|
|||||||
33
lintunes/paths.py
Normal file
33
lintunes/paths.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
"""Portable track paths.
|
||||||
|
|
||||||
|
Track locations are kept **absolute** in memory (the player, tagging, and art code
|
||||||
|
all expect real paths), but on disk they are stored **relative to the data dir**.
|
||||||
|
The data dir and the music live inside one Syncthing-shared tree and move together,
|
||||||
|
so a relative path resolves correctly on every machine no matter where the tree is
|
||||||
|
mounted — no per-machine ``music_root`` config is needed at load time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def to_relative(location: str, data_dir) -> str:
|
||||||
|
"""Convert an absolute track location to a path relative to ``data_dir``.
|
||||||
|
|
||||||
|
Empty or already-relative paths pass through unchanged. A file outside the
|
||||||
|
synced tree yields a ``..``-chain that still resolves on this machine (it
|
||||||
|
isn't synced, so it can't play on another machine regardless).
|
||||||
|
"""
|
||||||
|
if not location or not os.path.isabs(location):
|
||||||
|
return location
|
||||||
|
return os.path.relpath(location, str(data_dir))
|
||||||
|
|
||||||
|
|
||||||
|
def to_absolute(stored: str, data_dir) -> str:
|
||||||
|
"""Resolve a stored (data-dir-relative) path back to an absolute local path.
|
||||||
|
|
||||||
|
Absolute stored paths pass through unchanged, so libraries written before
|
||||||
|
portable paths (plain absolute locations) still load.
|
||||||
|
"""
|
||||||
|
if not stored or os.path.isabs(stored):
|
||||||
|
return stored
|
||||||
|
return os.path.normpath(os.path.join(str(data_dir), stored))
|
||||||
@ -2,6 +2,7 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from lintunes.models import Track, Playlist, PlaylistSettings, Library
|
from lintunes.models import Track, Playlist, PlaylistSettings, Library
|
||||||
|
from lintunes.paths import to_relative, to_absolute
|
||||||
|
|
||||||
|
|
||||||
def save_library(library: Library, data_dir: Path):
|
def save_library(library: Library, data_dir: Path):
|
||||||
@ -23,7 +24,14 @@ def save_library(library: Library, data_dir: Path):
|
|||||||
|
|
||||||
def save_tracks(library: Library, data_dir: Path):
|
def save_tracks(library: Library, data_dir: Path):
|
||||||
data_dir.mkdir(parents=True, exist_ok=True)
|
data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
tracks_dict = {str(tid): track.to_dict() for tid, track in library.tracks.items()}
|
tracks_dict = {}
|
||||||
|
for tid, track in library.tracks.items():
|
||||||
|
data = track.to_dict()
|
||||||
|
if "location" in data:
|
||||||
|
# Store paths relative to the data dir so the library is portable
|
||||||
|
# across machines that sync the folder to different mount points.
|
||||||
|
data["location"] = to_relative(data["location"], data_dir)
|
||||||
|
tracks_dict[str(tid)] = data
|
||||||
_write_json(data_dir / "library.json", tracks_dict)
|
_write_json(data_dir / "library.json", tracks_dict)
|
||||||
|
|
||||||
|
|
||||||
@ -62,6 +70,8 @@ def load_library(data_dir: Path) -> Library:
|
|||||||
tracks_dict = _read_json(library_path)
|
tracks_dict = _read_json(library_path)
|
||||||
for tid_str, track_data in tracks_dict.items():
|
for tid_str, track_data in tracks_dict.items():
|
||||||
track = Track.from_dict(track_data)
|
track = Track.from_dict(track_data)
|
||||||
|
# Stored relative to the data dir; resolve back to an absolute path.
|
||||||
|
track.location = to_absolute(track.location, data_dir)
|
||||||
tracks[track.track_id] = track
|
tracks[track.track_id] = track
|
||||||
|
|
||||||
# Load metadata
|
# Load metadata
|
||||||
|
|||||||
@ -1,5 +1,62 @@
|
|||||||
## Done
|
## Done
|
||||||
|
|
||||||
|
### Round 15 (2026-07-01) — Go live: portable multi-machine paths + final import
|
||||||
|
|
||||||
|
Prepared LinTunes to become the canonical library, synced to a second machine via
|
||||||
|
Syncthing. See `tests/test_round15.py` (10 tests).
|
||||||
|
|
||||||
|
- [x] **Portable track paths** (`lintunes/paths.py`) — track locations are stored
|
||||||
|
**relative to the data dir** (`to_relative`) and resolved back to absolute on load
|
||||||
|
(`to_absolute`), applied only at the `json_storage` boundary (`save_tracks` /
|
||||||
|
`load_library`). `Track.location` stays absolute in memory, so the player, tagging,
|
||||||
|
and art code are untouched. Because the data dir and the music live inside one
|
||||||
|
synced tree and move together, the library now resolves on any machine no matter
|
||||||
|
where Syncthing mounts the folder — no per-machine `music_root` config. Absolute
|
||||||
|
paths in older `library.json` files still load (back-compat).
|
||||||
|
- [x] **Final import + go live** — fresh import of the current `iTunes Library.xml`
|
||||||
|
into the synced data dir `/run/media/trav/tummult/music/lintunes/` (inside the
|
||||||
|
verified "music" Syncthing share), config saved via `--save-config`, preferences
|
||||||
|
(UI tuning + Last.fm) carried over from the old `./data`, which is retained as a
|
||||||
|
rollback backup. Git remote set to `git.autonomic.zone:2222/trav/lintunes` so the
|
||||||
|
second machine can clone.
|
||||||
|
|
||||||
|
### Round 14 (2026-06-26) — Smart playlists (Phase 1)
|
||||||
|
|
||||||
|
Auto-populating, criteria-driven playlists with full iTunes 12 import. See
|
||||||
|
`tests/test_round14.py` (27 tests, incl. real captured blobs in
|
||||||
|
`tests/smart_blobs.json`).
|
||||||
|
|
||||||
|
- [x] **Criteria model + evaluator** (`lintunes/smart.py`) — recursive
|
||||||
|
`SmartRule`/`SmartGroup`/`SmartLimit`/`SmartCriteria` (JSON round-tripping),
|
||||||
|
a shared `FIELD_REGISTRY` (field → Track attr, type, operators) used by both
|
||||||
|
evaluator and editor, and `evaluate()` (match all/any, nested groups,
|
||||||
|
string/int/duration/rating/date/bool ops, relative "in the last N", and
|
||||||
|
limit-by items/time/size with a "selected by" sort).
|
||||||
|
- [x] **iTunes import** — the binary "Smart Info"/"Smart Criteria" blobs are
|
||||||
|
decoded by a **vendored, MIT-licensed parser** (`lintunes/itunes_smart/`,
|
||||||
|
from github.com/cvzi/itunes_smartplaylist) and converted to our model by
|
||||||
|
`parse_itunes_smart()`. Nested rule groups **parse and evaluate** correctly;
|
||||||
|
blobs we can't represent (MediaKind/iCloud/etc.) flag `unsupported` and keep
|
||||||
|
the imported track snapshot. Validated against all 14 real smart playlists.
|
||||||
|
`loved` is deliberately dropped per user preference.
|
||||||
|
- [x] **Manager** (`library_manager.py`) — `create_smart_playlist` /
|
||||||
|
`set_smart_criteria` (undoable) / `recompute_smart_playlist` /
|
||||||
|
`recompute_all_smart`; field-scoped, coalesced live recompute hooked into the
|
||||||
|
track-edit/play/skip/add funnels (honours each playlist's `live_update`); a
|
||||||
|
no-op equality guard to avoid Syncthing churn; manual add/remove/reorder
|
||||||
|
blocked on smart playlists. `main.py` recomputes on load;
|
||||||
|
`conflict_resolver.py` takes newest criteria (no track_id union) for smart.
|
||||||
|
- [x] **GUI** — `❧ ` glyph on smart rows in the sidebar (with a rename-strip
|
||||||
|
delegate), distinct context menu (New/Edit Smart Playlist), read-only track
|
||||||
|
table for smart playlists, and `SmartPlaylistEditorDialog`
|
||||||
|
(`gui/smart_playlist_dialog.py`): per-field rule rows, match all/any, limits,
|
||||||
|
live-updating. New Smart Playlist on File menu (Ctrl+Alt+N).
|
||||||
|
|
||||||
|
**Phase 2 TODO:** nested-group **editing** UI in the dialog (imported nested
|
||||||
|
playlists are currently shown read-only with a banner; they still update/play).
|
||||||
|
|
||||||
|
### Round 8 (2026-06-19)
|
||||||
|
|
||||||
All four implemented in Round 8 (2026-06-19); see tests/test_round8.py.
|
All four implemented in Round 8 (2026-06-19); see tests/test_round8.py.
|
||||||
|
|
||||||
- [x] the ability to right click on any track and within the right-click menu that comes up there's an item that says "show in playlist..." with a right arrow. Hover that and you get a list of all the playlists that that track appears in. Select a playlist and the app jumps to that playlist and highlights the first instance of that song in that playlist.
|
- [x] the ability to right click on any track and within the right-click menu that comes up there's an item that says "show in playlist..." with a right arrow. Hover that and you get a list of all the playlists that that track appears in. Select a playlist and the app jumps to that playlist and highlights the first instance of that song in that playlist.
|
||||||
|
|||||||
110
tests/test_round15.py
Normal file
110
tests/test_round15.py
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
"""Round 15: portable, machine-independent track paths.
|
||||||
|
|
||||||
|
Track locations are stored relative to the data dir so a Syncthing-synced
|
||||||
|
library resolves correctly on a second machine that mounts the shared folder at
|
||||||
|
a different absolute path. See lintunes/paths.py and the storage boundary in
|
||||||
|
lintunes/storage/json_storage.py.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
from lintunes.paths import to_relative, to_absolute
|
||||||
|
from lintunes.storage.json_storage import save_library, load_library
|
||||||
|
from lintunes.models import Library, Track
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# to_relative / to_absolute
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
class TestPathHelpers:
|
||||||
|
def test_round_trip(self):
|
||||||
|
data_dir = "/run/media/trav/tummult/music/lintunes"
|
||||||
|
loc = "/run/media/trav/tummult/music/iTunes Media/Music/A/Song.m4a"
|
||||||
|
rel = to_relative(loc, data_dir)
|
||||||
|
assert rel == "../iTunes Media/Music/A/Song.m4a"
|
||||||
|
assert to_absolute(rel, data_dir) == loc
|
||||||
|
|
||||||
|
def test_resolves_under_a_different_root(self):
|
||||||
|
rel = "../iTunes Media/Music/A/Song.m4a"
|
||||||
|
# Same relative path, a different mount point -> a valid local path there.
|
||||||
|
assert to_absolute(rel, "/home/trav/synced/music/lintunes") == (
|
||||||
|
"/home/trav/synced/music/iTunes Media/Music/A/Song.m4a")
|
||||||
|
|
||||||
|
def test_empty_passes_through(self):
|
||||||
|
assert to_relative("", "/data") == ""
|
||||||
|
assert to_absolute("", "/data") == ""
|
||||||
|
|
||||||
|
def test_absolute_stored_path_is_left_alone(self):
|
||||||
|
# Back-compat: libraries written before portable paths kept absolute
|
||||||
|
# locations; those must load unchanged.
|
||||||
|
loc = "/run/media/trav/tummult/music/iTunes Media/x.mp3"
|
||||||
|
assert to_absolute(loc, "/some/other/lintunes") == loc
|
||||||
|
|
||||||
|
def test_already_relative_location_not_re_relativized(self):
|
||||||
|
assert to_relative("../iTunes Media/x.mp3", "/data/lintunes") == (
|
||||||
|
"../iTunes Media/x.mp3")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Storage boundary: JSON is portable, in-memory paths are absolute
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def _library(*locations):
|
||||||
|
lib = Library()
|
||||||
|
for i, loc in enumerate(locations, start=1):
|
||||||
|
lib.tracks[i] = Track(track_id=i, name=f"t{i}", location=loc)
|
||||||
|
return lib
|
||||||
|
|
||||||
|
|
||||||
|
class TestPortableStorage:
|
||||||
|
def test_saved_json_is_relative(self, tmp_path):
|
||||||
|
data_dir = tmp_path / "music" / "lintunes"
|
||||||
|
loc = str(tmp_path / "music" / "iTunes Media" / "A" / "Song.m4a")
|
||||||
|
save_library(_library(loc), data_dir)
|
||||||
|
|
||||||
|
raw = json.loads((data_dir / "library.json").read_text())
|
||||||
|
stored = raw["1"]["location"]
|
||||||
|
assert stored == os.path.join("..", "iTunes Media", "A", "Song.m4a")
|
||||||
|
assert str(tmp_path) not in stored # no machine-specific prefix leaked
|
||||||
|
|
||||||
|
def test_load_resolves_absolute(self, tmp_path):
|
||||||
|
data_dir = tmp_path / "music" / "lintunes"
|
||||||
|
loc = str(tmp_path / "music" / "iTunes Media" / "A" / "Song.m4a")
|
||||||
|
save_library(_library(loc), data_dir)
|
||||||
|
|
||||||
|
lib = load_library(data_dir)
|
||||||
|
assert lib.tracks[1].location == loc
|
||||||
|
|
||||||
|
def test_second_machine_different_mount(self, tmp_path):
|
||||||
|
# Machine 1 writes the library under rootA/music/...
|
||||||
|
root_a = tmp_path / "rootA"
|
||||||
|
data_a = root_a / "music" / "lintunes"
|
||||||
|
loc_a = str(root_a / "music" / "iTunes Media" / "A" / "Song.m4a")
|
||||||
|
save_library(_library(loc_a), data_a)
|
||||||
|
|
||||||
|
# Syncthing replicates the whole "music" folder to a different path.
|
||||||
|
root_b = tmp_path / "rootB"
|
||||||
|
(root_b).mkdir()
|
||||||
|
shutil.copytree(root_a / "music", root_b / "music")
|
||||||
|
|
||||||
|
lib_b = load_library(root_b / "music" / "lintunes")
|
||||||
|
assert lib_b.tracks[1].location == str(
|
||||||
|
root_b / "music" / "iTunes Media" / "A" / "Song.m4a")
|
||||||
|
|
||||||
|
def test_empty_location_round_trips(self, tmp_path):
|
||||||
|
data_dir = tmp_path / "music" / "lintunes"
|
||||||
|
save_library(_library(""), data_dir)
|
||||||
|
lib = load_library(data_dir)
|
||||||
|
assert lib.tracks[1].location == ""
|
||||||
|
|
||||||
|
def test_absolute_json_still_loads(self, tmp_path):
|
||||||
|
# A pre-portability library.json (absolute paths) must still load.
|
||||||
|
data_dir = tmp_path / "music" / "lintunes"
|
||||||
|
data_dir.mkdir(parents=True)
|
||||||
|
loc = "/elsewhere/iTunes Media/x.mp3"
|
||||||
|
(data_dir / "library.json").write_text(json.dumps(
|
||||||
|
{"1": {"track_id": 1, "name": "t1", "location": loc}}))
|
||||||
|
lib = load_library(data_dir)
|
||||||
|
assert lib.tracks[1].location == loc
|
||||||
Reference in New Issue
Block a user