Files
lintunes/tests/test_round15.py
trav 2c5b5a575f 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>
2026-07-01 16:43:57 -04:00

111 lines
4.5 KiB
Python

"""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