v0.10.0: a first launch that welcomes you

Three things that only ever hurt new users.

The startup font modal is gone. _ensure_now_playing_font ran before
MainWindow existed, so on a machine without Century Gothic the very first
thing LinTunes did was open a parentless dialog demanding a font decision.
theme.NOW_PLAYING_FALLBACKS now picks the closest geometric sans installed
(URW Gothic, the Avant Garde clone CG derives from, leads the chain), and
the choice moved to Preferences ▸ Now-playing font. An uninstalled saved
family falls back to automatic instead of the app default.

A non-iTunes user can finally set their music folder. library.music_folder
was written in exactly one place — the iTunes importer — and with it unset
_music_import_dir fell back to a *relative* Path("Music"), resolved against
a working directory GNOME's dash does not set predictably (see
packaging/install-desktop.sh). Music scattered somewhere unfindable. Now
the first launch asks one plain-language question, Preferences can change
it later, and an import with no folder set refuses rather than guessing.
Picking ~/Music files into ~/Music, not ~/Music/Music.

music_folder is portable at last. It was the only path in the library
stored raw absolute in the *synced* metadata, so machine 2 inherited
machine 1's paths. It is now also stored relative to the data dir, the
same trick Track.location has used all along. The absolute key stays
forever as the shared floor between versions: old code reads it and
behaves exactly as before, and old code that writes the file just drops
the new keys, so no version combination hard-fails.

Two traps worth naming. set_music_folder must call
mark_library_settings_dirty() or reload_from_disk reverts the change on
the next sync tick. And the dirty flag only guards until flush, so a
music_folder_set_at stamp decides adoption semantically — _merge_metadata
picks the whole file by mtime, which moves when someone resizes a column
(the Round 39 lesson, applied to metadata).

Also: correct CLAUDE.md's claim that the importer skips smart playlists —
it imports them; system playlists are what's skipped. README gains a
"never used a terminal?" on-ramp and loses the instruction to hand-write
library_metadata.json before first launch.

Verified against the live 21,490-track library: still resolves (via the
legacy key), metadata untouched, 646 tests pass.

Claude-Session: https://claude.ai/code/session_01G4Z46BMQYS57bcbxWbSS3C
This commit is contained in:
2026-08-22 13:04:24 -04:00
parent 19aa816537
commit 398e0312f8
15 changed files with 1340 additions and 124 deletions
+81 -4
View File
@@ -7,9 +7,11 @@ from PyQt6.QtCore import QObject, QTimer, pyqtSignal
log = logging.getLogger(__name__)
from lintunes import music_folder as music_folder_mod
from lintunes import smart, tagging, trash
from lintunes.importers.file_importer import organized_destination, unique_path
from lintunes.models import Library, Playlist, PlaylistType
from lintunes.paths import to_relative
from lintunes.perf import timed
from lintunes.storage import json_storage
from lintunes.storage.play_journal import PlayJournal
@@ -42,6 +44,7 @@ class LibraryManager(QObject):
file_move_failed = pyqtSignal(str, str) # track name, error text
tracks_removed = pyqtSignal(list) # track ids gone from the library
tracks_restored = pyqtSignal(list) # track ids back after an undo
music_folder_changed = pyqtSignal(str) # new media folder ('' if none)
def __init__(self, library: Library, data_dir: Path, parent=None):
super().__init__(parent)
@@ -49,6 +52,12 @@ class LibraryManager(QObject):
self.data_dir = Path(data_dir)
self.undo_stack = UndoStack(parent=self)
# Resolved once and cached: organize_root() is consulted on every tag
# edit, and resolution stats the filesystem.
self._music_folder: Path | None = None
self._music_folder_source = "unset"
self.refresh_music_folder()
self._dirty_tracks = False
self._dirty_metadata = False
self._dirty_playlists: set[str] = set()
@@ -626,12 +635,76 @@ class LibraryManager(QObject):
_ORGANIZE_FIELDS = frozenset({"artist", "album_artist", "album"})
# ---- music folder ----
def refresh_music_folder(self):
"""Re-resolve which media folder applies on this machine."""
state = music_folder_mod.resolve(self.library, self.data_dir)
self._music_folder = state.path
self._music_folder_source = state.source
def music_folder_state(self):
"""Full resolution (path + where it came from), for the GUI."""
return music_folder_mod.resolve(self.library, self.data_dir)
def _should_adopt_music_folder(self, disk) -> bool:
"""Whether a synced-in library_metadata.json may replace our setting.
The dirty flag only protects us until the next flush; after that a
*stale* metadata file arriving from the other machine would silently
revert a deliberate change. So compare the semantic stamp instead of
the file's mtime, which moves for cosmetic reasons (the Round 39
lesson, applied to metadata).
"""
ours = self.library.music_folder_set_at
if not ours:
# Legacy library, or never set here — keep the old behavior of
# trusting disk, so nothing regresses for existing libraries.
return True
theirs = disk.music_folder_set_at
return bool(theirs) and theirs >= ours
def organize_root(self) -> Path | None:
"""Root of the organized music tree (<music_folder>/Music) — the only
place imports copy into and rename-moves manage."""
if not self.library.music_folder:
place imports copy into and rename-moves manage.
None when no music folder is set *or* the one on file isn't present on
this machine (an unmounted drive). Callers must handle None rather than
inventing a path: a relative fallback would resolve against the process
working directory, which GNOME's dash does not set predictably.
"""
if self._music_folder is None:
return None
return Path(self.library.music_folder) / "Music"
return self._music_folder / "Music"
def set_music_folder(self, chosen, *, machine_only: bool = False) -> Path:
"""Point the library at a media folder. Returns the stored folder.
``machine_only`` writes just this machine's override in config.json and
leaves the synced library metadata untouched — used when the shared
value is real but simply isn't reachable here, so one machine can never
clobber another's setting.
"""
media = music_folder_mod.normalize_media_folder(chosen)
if machine_only:
music_folder_mod.set_machine_override(str(media))
else:
# A stale override would otherwise keep shadowing the new shared
# value on this machine.
music_folder_mod.set_machine_override(None)
self.library.music_folder = str(media)
self.library.music_folder_rel = to_relative(str(media), self.data_dir)
self.library.music_folder_set_at = _utc_now_iso()
# Mandatory: without the dirty mark, reload_from_disk's
# `if not self._dirty_metadata` branch silently reverts this the
# next time the sync watcher fires. Flushing immediately shrinks
# that window to nothing — dirty means protected, flushed means
# disk already agrees with us.
self.mark_library_settings_dirty()
self.flush()
self.refresh_music_folder()
self.music_folder_changed.emit(str(self._music_folder or ""))
return media
def _maybe_move_file(self, track, changed: dict) -> str | None:
"""If an artist/album_artist/album edit changes where the file belongs
@@ -935,9 +1008,13 @@ class LibraryManager(QObject):
self.library.playlists.pop(pid, None)
if not self._dirty_metadata:
self.library.music_folder = disk.music_folder
if self._should_adopt_music_folder(disk):
self.library.music_folder = disk.music_folder
self.library.music_folder_rel = disk.music_folder_rel
self.library.music_folder_set_at = disk.music_folder_set_at
self.library.import_date = disk.import_date
self.library.library_settings = disk.library_settings
self.refresh_music_folder()
self._max_track_id = max(self.library.tracks.keys(), default=0)