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>
111 lines
4.5 KiB
Python
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
|