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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user