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.
This commit is contained in:
2026-07-01 16:43:57 -04:00
parent 2917a42b75
commit 37fadd38d6
5 changed files with 231 additions and 8 deletions
+33
View 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))
+11 -1
View File
@@ -2,6 +2,7 @@ import json
from pathlib import Path
from lintunes.models import Track, Playlist, PlaylistSettings, Library
from lintunes.paths import to_relative, to_absolute
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):
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)
@@ -62,6 +70,8 @@ def load_library(data_dir: Path) -> Library:
tracks_dict = _read_json(library_path)
for tid_str, track_data in tracks_dict.items():
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
# Load metadata