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
+39 -11
View File
@@ -9,10 +9,25 @@ from PyQt6.QtGui import QPalette, QColor, QFontDatabase
# The font the now-playing panel wants by default. Not bundled (it's a
# proprietary Monotype face); the user is asked to install it and, if missing,
# to pick a substitute on first run (see main.ensure_now_playing_font).
# proprietary Monotype face), so it's simply the head of the fallback chain
# below: whatever geometric sans this machine actually has wins, silently.
# Startup never asks — the user can override in Edit ▸ Preferences.
CENTURY_GOTHIC = "Century Gothic"
# Closest-first substitutes for Century Gothic. URW Gothic is the ITC Avant
# Garde clone CG derives from and ships with ghostscript, so it's present on
# most Linux boxes; TeX Gyre Adventor is its TeX-world descendant. Names must
# be spelled exactly as the family is registered — QFontDatabase does no fuzzy
# matching, which is why "Jost*" appears with its asterisk.
NOW_PLAYING_FALLBACKS = (
CENTURY_GOTHIC,
"URW Gothic", "URW Gothic L", "URW Gothic Book",
"TeX Gyre Adventor",
"Questrial", "Didact Gothic", "Jost*", "Jost", "Josefin Sans",
"Poppins", "Futura", "Avant Garde", "ITC Avant Garde Gothic",
"Montserrat", "Nunito Sans", "Nunito",
)
HIGHLIGHT_COLORS = {
"blue": "#3584E4",
@@ -141,23 +156,36 @@ def apply_theme(app, prefs):
""")
def century_gothic_available() -> bool:
"""True if the Century Gothic family is installed and resolvable by Qt."""
return CENTURY_GOTHIC in QFontDatabase.families()
def installed_families() -> set[str]:
"""Families Qt can actually resolve on this machine."""
return set(QFontDatabase.families())
def auto_now_playing_family() -> str | None:
"""Best available face from NOW_PLAYING_FALLBACKS, or None for the app font.
Deliberately uncached: a cache would go stale for tests that stub
QFontDatabase, and for a font installed while the app is running.
"""
families = installed_families()
return next((f for f in NOW_PLAYING_FALLBACKS if f in families), None)
def now_playing_font_family(prefs) -> str | None:
"""Family the now-playing panel should use, or None for the app default.
prefs["now_playing_font"] semantics:
- missing/None : never set → prefer Century Gothic, fall back to default
- "" : user chose "use default" → always the app default
- "Family" : user picked a family → use it (if still installed)
- missing/None : automatic → best available face from the fallback chain
- "" : user chose "same as the app" → always the app default
- "Family" : user picked a family → use it, or fall back to automatic
if it has since been uninstalled
Never prompts and never blocks; an unavailable wish quietly becomes the
nearest thing installed.
"""
saved = prefs.get("now_playing_font")
if saved == "":
return None
if saved: # a previously chosen family
return saved if saved in QFontDatabase.families() else None
# Never set: prefer Century Gothic when present, else default.
return CENTURY_GOTHIC if century_gothic_available() else None
return saved if saved in installed_families() else auto_now_playing_family()
return auto_now_playing_family()