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
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.9.1"
__version__ = "0.10.0"
+111 -9
View File
@@ -9,7 +9,7 @@ from PyQt6.QtWidgets import (
from PyQt6.QtCore import Qt, QEvent, QTimer
from PyQt6.QtGui import QAction, QKeySequence
from lintunes import device_sync, mpris, tagging, theme
from lintunes import device_sync, mpris, music_folder, tagging, theme
from lintunes.art_search import AlbumArtFetcher
from lintunes.cast.controller import CastController
from lintunes.eventlog import log_control
@@ -25,6 +25,7 @@ from lintunes.gui.library_view import LibraryView
from lintunes.gui.playlist_view import PlaylistView
from lintunes.gui.transport import TransportBar
from lintunes.gui.info_dialog import InfoDialog
from lintunes.gui.music_folder_dialog import MusicFolderDialog
from lintunes.gui.preferences_dialog import PreferencesDialog
from lintunes.gui.track_table import format_total_time
from lintunes.gui.version_button import VersionButton
@@ -126,6 +127,7 @@ class MainWindow(QMainWindow):
# stretch would squeeze the message area to zero width and swallow
# every message.
self.restart_requested = False # run_gui re-execs when True on quit
self._missing_folder_prompted = False
self._updater = Updater(self)
self._version_button = VersionButton(self._updater)
self.statusBar().addWidget(self._version_button)
@@ -261,7 +263,12 @@ class MainWindow(QMainWindow):
def _show_preferences(self):
if self._prefs_dialog is None:
self._prefs_dialog = PreferencesDialog(self._prefs, self._lastfm, self)
self._prefs_dialog = PreferencesDialog(
self._prefs, self._lastfm, self,
music_folder=self._music_folder_summary,
on_music_folder_change=self._change_music_folder)
else:
self._prefs_dialog.refresh_music_folder()
self._prefs_dialog.show()
self._prefs_dialog.raise_()
self._prefs_dialog.activateWindow()
@@ -554,8 +561,10 @@ class MainWindow(QMainWindow):
return
details = web_dialog.values()
start_dir = self._music_import_dir()
parent_dir = QFileDialog.getExistingDirectory(
self, "Export To", str(self._music_import_dir().parent))
self, "Export To",
str(start_dir.parent) if start_dir else str(Path.home()))
if not parent_dir:
return
@@ -777,12 +786,94 @@ class MainWindow(QMainWindow):
ids = view.table.selected_track_ids()
self._show_info(ids)
# ---- music folder ----
def check_music_folder(self):
"""Ask where the music goes, if we don't already know.
Called once shortly after the window appears, and again lazily if an
import happens while no folder is set. Never blocks startup: by the
time this runs the main window is already up, so the dialog is a normal
child dialog rather than a popup in front of an empty screen.
"""
state = self._manager.music_folder_state()
if state.path is not None:
return
if state.source == "unset":
if music_folder.prompt_seen():
return
elif self._missing_folder_prompted:
return # asked once this session; don't nag on every import
dialog = MusicFolderDialog(
self, missing_path=state.stored if state.source == "missing" else "")
dialog.exec()
chosen = dialog.chosen_folder()
# Remember we asked, whatever the answer — including "Not Now".
music_folder.mark_prompt_seen()
self._missing_folder_prompted = True
if chosen is not None:
# A folder that exists elsewhere but not here is this machine's own
# problem to solve; don't overwrite what the other machine set.
self._manager.set_music_folder(
chosen, machine_only=(state.source == "missing"))
self.statusBar().showMessage(
f"Music folder set to {self._manager.organize_root()}", 6000)
def _change_music_folder(self):
"""Preferences ▸ Music folder ▸ Change… — pick a new one."""
state = self._manager.music_folder_state()
start = str(state.path) if state.path else str(Path.home())
chosen = QFileDialog.getExistingDirectory(
self, "Choose Music Folder", start)
if not chosen:
return
media = music_folder.normalize_media_folder(chosen)
old_root = self._manager.organize_root()
if old_root is not None and old_root.exists() and \
media != Path(state.path or ""):
# Say the quiet part out loud: nothing moves, and from here on only
# new music goes to the new place. Otherwise the change looks total
# but silently applies to future additions only.
answer = QMessageBox.question(
self, "Change music folder?",
f"Your existing songs will stay where they are:\n{old_root}\n\n"
f"Only music you add from now on will go to:\n"
f"{music_folder.organize_root_for(media)}\n\n"
"Change the folder?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No)
if answer != QMessageBox.StandardButton.Yes:
return
self._manager.set_music_folder(
media, machine_only=state.is_machine_local)
self.statusBar().showMessage(
f"Music folder set to {self._manager.organize_root()}", 6000)
def _music_folder_summary(self) -> tuple[str, str]:
"""(folder, note) for the Preferences row."""
state = self._manager.music_folder_state()
if state.path is None:
return ("", "Not set yet — pick a folder to start adding music.")
if state.is_machine_local:
return (str(state.path), "Used on this computer only.")
return (str(state.path), "Shared with your other computers.")
# ---- file import ----
def _music_import_dir(self) -> Path:
# Fall back to ./Music when no music folder is set (fresh library),
# matching the pre-organize_root behavior.
return self._manager.organize_root() or Path("Music")
def _music_import_dir(self) -> Path | None:
"""Where imports are filed, or None when no music folder is usable.
Deliberately no fallback. The old `or Path("Music")` was *relative*, so
it resolved against the process working directory — which GNOME's dash
does not set predictably (see packaging/install-desktop.sh), scattering
a new user's music somewhere they'd never find it.
"""
return self._manager.organize_root()
def _open_files_dialog(self):
paths, _filter = QFileDialog.getOpenFileNames(
@@ -793,9 +884,20 @@ class MainWindow(QMainWindow):
def import_files(self, paths, playlist_pid: str = "",
position: int | None = None):
music_dir = self._music_import_dir()
if music_dir is None:
# Ask rather than guess — better one question than music copied
# somewhere unfindable.
self.check_music_folder()
music_dir = self._music_import_dir()
if music_dir is None:
self.statusBar().showMessage(
"Choose where to keep your music first — Edit ▸ Preferences.",
8000)
return []
self.statusBar().showMessage(f"Importing {len(paths)} item(s)…")
imported = file_importer.import_paths(
paths, self._music_import_dir(), self._manager, playlist_pid,
paths, music_dir, self._manager, playlist_pid,
position)
self.statusBar().showMessage(f"Imported {len(imported)} track(s)", 5000)
if imported:
@@ -981,7 +1083,7 @@ class MainWindow(QMainWindow):
if p.exists():
return str(p)
music = self._music_import_dir()
return str(music) if music.exists() else str(Path.home())
return str(music) if music and music.exists() else str(Path.home())
def _on_track_missing(self, track):
"""A track's file is gone (moved/renamed outside lintunes). Offer to
+113
View File
@@ -0,0 +1,113 @@
"""Ask, in plain language, where the music should live.
Two variants of the same dialog:
* **unset** — a brand-new library. Shown once per machine, right after the main
window appears, and answering is entirely optional.
* **missing** — the folder on file isn't on this computer (an unplugged drive,
or a Syncthing share laid out differently here). Whatever the user picks is
recorded for *this* machine only, so the other one is left alone.
Wording is deliberately jargon-free: no "path", no "directory", no "root".
"""
from pathlib import Path
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton,
QFileDialog, QDialogButtonBox)
from lintunes import music_folder
class MusicFolderDialog(QDialog):
"""Returns the chosen folder via ``chosen_folder()``, or None if skipped."""
def __init__(self, parent=None, *, missing_path: str = ""):
super().__init__(parent)
self._missing = bool(missing_path)
self.setWindowTitle("Your music folder" if self._missing
else "Welcome to LinTunes")
self.setModal(True)
layout = QVBoxLayout(self)
layout.setSpacing(10)
if self._missing:
headline = "LinTunes can't find your music folder on this computer."
body = (f"It's set to:\n{missing_path}\n\n"
"That folder isn't here right now — the drive may be "
"unplugged, or this computer may keep its music somewhere "
"else. Pick where the music lives on this computer.\n\n"
"Your other computer keeps its own setting, so this only "
"changes things here.")
else:
headline = "Where should LinTunes keep your music?"
body = ("When you add songs, LinTunes copies them into one folder "
"and files them away by artist and album — so your music "
"stays tidy no matter where it came from.\n\n"
"You can change this later in Preferences.")
title = QLabel(headline)
title.setStyleSheet("font-weight: bold;")
title.setWordWrap(True)
layout.addWidget(title)
explain = QLabel(body)
explain.setWordWrap(True)
layout.addWidget(explain)
row = QHBoxLayout()
self._folder = QLineEdit(str(Path.home() / "Music"))
self._folder.setReadOnly(True)
row.addWidget(self._folder, stretch=1)
browse = QPushButton("Browse…")
browse.clicked.connect(self._choose)
row.addWidget(browse)
layout.addLayout(row)
# Spell out the actual result, so nobody has to guess what the
# <folder>/Music layout does to the folder they picked.
self._preview = QLabel()
self._preview.setWordWrap(True)
self._preview.setTextFormat(Qt.TextFormat.PlainText)
layout.addWidget(self._preview)
buttons = QDialogButtonBox()
self._use = buttons.addButton("Use This Folder",
QDialogButtonBox.ButtonRole.AcceptRole)
buttons.addButton("Not Now", QDialogButtonBox.ButtonRole.RejectRole)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
self._refresh_preview()
# ---- internals ----
def _choose(self):
start = self._folder.text() or str(Path.home())
chosen = QFileDialog.getExistingDirectory(
self, "Choose Music Folder", start)
if chosen:
self._folder.setText(chosen)
self._refresh_preview()
def _refresh_preview(self):
text = self._folder.text()
if not text:
self._preview.setText("")
return
root = music_folder.organize_root_for(
music_folder.normalize_media_folder(text))
self._preview.setText(f"Songs will be filed in {root}/Artist/Album")
# ---- result ----
def chosen_folder(self) -> Path | None:
"""The folder the user accepted, or None if they skipped."""
if self.result() != QDialog.DialogCode.Accepted:
return None
text = self._folder.text()
return Path(text) if text else None
+100 -3
View File
@@ -1,10 +1,10 @@
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, QSlider,
QRadioButton, QButtonGroup, QLineEdit, QPushButton, QCheckBox,
QFormLayout,
QFormLayout, QFontComboBox,
)
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QPixmap, QColor, QIcon
from PyQt6.QtGui import QPixmap, QColor, QIcon, QFont
from lintunes import theme
from lintunes.theme import HIGHLIGHT_COLORS
@@ -30,10 +30,21 @@ def _swatch(color_hex: str) -> QIcon:
class PreferencesDialog(QDialog):
def __init__(self, prefs, lastfm, parent=None):
"""App preferences. Applies immediately — there is no OK/Cancel.
``music_folder``/``on_music_folder_change`` are optional callbacks rather
than a LibraryManager reference, keeping this dialog manager-free (the same
injection style as track_table's ``playlists_for_track``). When they are
omitted the music-folder row is simply left out.
"""
def __init__(self, prefs, lastfm, parent=None, *,
music_folder=None, on_music_folder_change=None):
super().__init__(parent)
self._prefs = prefs
self._lastfm = lastfm
self._music_folder = music_folder
self._on_music_folder_change = on_music_folder_change
self.setWindowTitle("Preferences")
self.setMinimumWidth(420)
@@ -46,6 +57,8 @@ class PreferencesDialog(QDialog):
layout = QVBoxLayout(self)
layout.addWidget(self._build_general_group())
if self._music_folder is not None:
layout.addWidget(self._build_music_group())
layout.addWidget(self._build_colors_group())
layout.addWidget(self._build_lastfm_group())
layout.addStretch()
@@ -93,8 +106,92 @@ class PreferencesDialog(QDialog):
color_row.addWidget(radio)
color_row.addStretch()
form.addRow("Highlight:", color_row)
# Now-playing font. Three states, so three radios: automatic (None),
# the app's own font (""), or an explicit family.
font_row = QHBoxLayout()
self._font_mode = QButtonGroup(self)
self._font_auto = QRadioButton("Automatic")
self._font_auto.setToolTip(
"Century Gothic if you have it, otherwise the closest match installed")
self._font_default = QRadioButton("Same as the app")
self._font_pick = QRadioButton("Choose:")
for button in (self._font_auto, self._font_default, self._font_pick):
self._font_mode.addButton(button)
font_row.addWidget(button)
self._font_combo = QFontComboBox()
font_row.addWidget(self._font_combo, stretch=1)
# State before signals, or building the dialog would write a pref.
saved = self._prefs.get("now_playing_font")
resolved = theme.now_playing_font_family(self._prefs) or self.font().family()
self._font_combo.setCurrentFont(QFont(resolved))
self._font_combo.setEnabled(bool(saved))
(self._font_default if saved == ""
else self._font_pick if saved
else self._font_auto).setChecked(True)
self._font_auto.toggled.connect(
lambda on: on and self._set_font_mode(None))
self._font_default.toggled.connect(
lambda on: on and self._set_font_mode(""))
self._font_pick.toggled.connect(
lambda on: on and self._set_font_mode(self._font_combo.currentFont().family()))
self._font_combo.currentFontChanged.connect(self._on_font_chosen)
form.addRow("Now-playing font:", font_row)
return group
def _set_font_mode(self, value):
self._font_combo.setEnabled(value not in (None, ""))
self._prefs.set("now_playing_font", value)
if value is None:
# Show what "automatic" actually landed on.
resolved = theme.now_playing_font_family(self._prefs)
if resolved:
self._font_combo.blockSignals(True)
self._font_combo.setCurrentFont(QFont(resolved))
self._font_combo.blockSignals(False)
def _on_font_chosen(self, font):
if self._font_pick.isChecked():
self._prefs.set("now_playing_font", font.family())
# ---- music folder ----
def _build_music_group(self) -> QGroupBox:
group = QGroupBox("Music folder")
form = QFormLayout(group)
row = QHBoxLayout()
self._music_edit = QLineEdit()
self._music_edit.setReadOnly(True)
row.addWidget(self._music_edit, stretch=1)
change = QPushButton("Change…")
change.clicked.connect(self._change_music_folder)
row.addWidget(change)
form.addRow("Keep music in:", row)
self._music_note = QLabel()
self._music_note.setWordWrap(True)
form.addRow("", self._music_note)
self.refresh_music_folder()
return group
def refresh_music_folder(self):
"""Repopulate the row from the injected getter."""
if self._music_folder is None or not hasattr(self, "_music_edit"):
return
folder, note = self._music_folder()
self._music_edit.setText(folder)
self._music_edit.setPlaceholderText("Not set")
self._music_note.setText(note)
def _change_music_folder(self):
if self._on_music_folder_change is not None:
self._on_music_folder_change()
self.refresh_music_folder()
def _on_size_changed(self, value: int):
scale = _SCALES[value]
self._size_label.setText(scale.capitalize())
+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)
+12 -43
View File
@@ -86,6 +86,14 @@ def run_import(xml_path: Path, music_root: str | None, data_dir: Path):
library, report = import_itunes_xml(xml_path, Path(music_root))
print(report.summary())
# The importer has no data dir, so the portable spelling of the music
# folder is filled in here, where we do.
from datetime import datetime, timezone
from lintunes.paths import to_relative
library.music_folder_rel = to_relative(library.music_folder, data_dir)
library.music_folder_set_at = (
datetime.now(timezone.utc).replace(tzinfo=None).isoformat())
if report.missing_files:
missing_log = data_dir / "import_missing_files.txt"
data_dir.mkdir(parents=True, exist_ok=True)
@@ -112,7 +120,7 @@ def run_gui(data_dir: Path, files: list[Path],
logging.basicConfig(level=logging.INFO)
from PyQt6.QtCore import QTimer
from PyQt6.QtWidgets import QApplication, QMessageBox, QFontDialog
from PyQt6.QtWidgets import QApplication
from lintunes import theme
from lintunes.storage.json_storage import load_library
@@ -138,7 +146,6 @@ def run_gui(data_dir: Path, files: list[Path],
prefs = Preferences(data_dir)
theme.apply_theme(app, prefs)
_ensure_now_playing_font(prefs)
manager = LibraryManager(library, data_dir)
lastfm = LastFm(prefs, data_dir)
window = MainWindow(manager, prefs, lastfm)
@@ -155,6 +162,9 @@ def run_gui(data_dir: Path, files: list[Path],
watcher.changed.connect(manager.check_for_external_changes)
window.show()
# Ask where the music goes only once the window is up, so a new user is
# never met by a dialog floating in front of nothing.
QTimer.singleShot(0, window.check_music_folder)
# Rebuild smart-playlist membership from criteria now that the full library
# (and any merged sync conflicts) is loaded. Deferred until after the first
# paint: it's a full pass over every track per smart playlist, and running
@@ -170,46 +180,5 @@ def run_gui(data_dir: Path, files: list[Path],
sys.exit(exit_code)
def _ensure_now_playing_font(prefs):
"""Prompt for a now-playing font only when needed, then persist the choice.
Stays silent when Century Gothic is installed (and the user hasn't picked a
substitute), or when the user has already chosen "use default". Prompts
when the desired family is missing, offering "Pick a font…" (QFontDialog)
or "Use default". Canceling the picker leaves prefs untouched so it
re-prompts next launch (lets the user install Century Gothic and retry).
"""
from PyQt6.QtWidgets import QMessageBox, QFontDialog
from lintunes import theme
desired = theme.now_playing_font_family(prefs)
if desired is None and prefs.get("now_playing_font") == "":
return # user already chose the app default
if desired is not None:
return # resolved to an installed family (CG or a saved substitute)
saved = prefs.get("now_playing_font")
if saved is None:
msg = ("LinTunes looks best with Century Gothic, which isn't installed. "
"Install it (~/.local/share/fonts/) and restart, or pick a font "
"to use for the now-playing panel now.")
else:
msg = (f"The font saved for the now-playing panel ({saved}) is no "
"longer installed. Pick a different font, or use the app default.")
box = QMessageBox(QMessageBox.Icon.Warning, "Now-playing font", msg)
pick = box.addButton("Pick a font…", QMessageBox.ButtonRole.AcceptRole)
default = box.addButton("Use default", QMessageBox.ButtonRole.RejectRole)
box.exec()
if box.clickedButton() is default:
prefs.set("now_playing_font", "")
return
if box.clickedButton() is not pick:
return # closed via X / escape → leave untouched, re-prompt next run
font, ok = QFontDialog.getFont()
if not ok or font.family() in ("", None):
return
prefs.set("now_playing_font", font.family())
if __name__ == "__main__":
main()
+13
View File
@@ -9,7 +9,20 @@ from .playlist import Playlist, PlaylistSettings
class Library:
tracks: dict[int, Track] = field(default_factory=dict) # track_id -> Track
playlists: dict[str, Playlist] = field(default_factory=dict) # persistent_id -> Playlist
# Where the organized music tree lives. Three fields, because the value has
# to survive both an older LinTunes reading this library and a Syncthing
# merge that picks files by mtime:
# music_folder absolute — what pre-0.10 code reads; kept forever as
# the shared floor between versions
# music_folder_rel relative to the data dir — the portable one, so a
# synced library resolves wherever each machine mounts
# it (same trick as Track.location, see paths.py)
# music_folder_set_at ISO UTC stamp of the last deliberate change, so a
# merge can prefer the newest *setting* rather than
# the newest *file* (mtime moves for cosmetic reasons)
music_folder: str = ""
music_folder_rel: str = ""
music_folder_set_at: Optional[str] = None
import_date: Optional[str] = None
# Column/sort settings for the all-tracks Library view
library_settings: PlaylistSettings = field(
+143
View File
@@ -0,0 +1,143 @@
"""Where the organized music tree lives, on *this* machine.
The library stores the music folder three ways (see `models/library.py`), and
which one applies depends on the machine you are sitting at. `resolve()` is the
single place that decides, in this order:
1. ``music_folder_rel`` resolved against the data dir — the portable value.
Both the music and the data dir ride one Syncthing share, so the *shape*
("the media folder is one level up from my data dir") is identical on every
machine even when the absolute paths are not.
2. ``music_folder`` — the legacy absolute value. Libraries written before 0.10,
and libraries last written by an older LinTunes, only have this one.
3. The machine-local override in ``~/.config/lintunes/config.json``.
The override is consulted **last, not first**, on purpose. If it won outright, a
machine that once needed an override would be pinned to it forever and would
never pick up a later shared change. Consulting it only after the shared value
fails to resolve gives an escape hatch *and* self-healing: plug the drive back
in and the shared setting takes over again on its own.
Every candidate must be an existing directory. A path that isn't there is not a
music folder — better to say "I can't find it" than to silently rebuild a
phantom tree next to wherever the app happened to be launched from.
"""
from dataclasses import dataclass
from pathlib import Path
from lintunes.config import load_config, save_config
from lintunes.paths import to_absolute
# Keys in the per-machine config.json (never synced).
OVERRIDE_KEY = "music_folder_override"
PROMPT_SEEN_KEY = "music_folder_prompted"
@dataclass(frozen=True)
class Resolution:
"""What the music folder resolved to here, and where the answer came from."""
path: Path | None # an existing media folder, or None
source: str # library | legacy | machine | missing | unset
stored: str = "" # what we tried, for the "can't find it" copy
@property
def is_machine_local(self) -> bool:
return self.source == "machine"
def _existing_dir(candidate: str) -> Path | None:
if not candidate:
return None
path = Path(candidate).expanduser()
return path if path.is_dir() else None
def resolve(library, data_dir, config=None) -> Resolution:
"""Pick the media folder for this machine. See the module docstring."""
config = load_config() if config is None else config
stored = ""
rel = getattr(library, "music_folder_rel", "")
if rel:
stored = to_absolute(rel, str(data_dir))
found = _existing_dir(stored)
if found is not None:
return Resolution(found, "library", stored)
legacy = getattr(library, "music_folder", "")
if legacy:
stored = stored or legacy
found = _existing_dir(legacy)
if found is not None:
return Resolution(found, "legacy", legacy)
override = config.get(OVERRIDE_KEY, "")
if override:
found = _existing_dir(override)
if found is not None:
return Resolution(found, "machine", override)
stored = stored or override
# Something is on file but not on this machine, versus never set at all —
# two different conversations to have with the user.
return Resolution(None, "missing" if stored else "unset", stored)
def normalize_media_folder(chosen) -> Path:
"""Turn the folder a user picked into the value we store.
``organize_root()`` is ``<media folder>/Music`` — the iTunes layout, where
an "iTunes Media" folder contains "Music". Taken literally, a newcomer who
picks ``~/Music`` would get ``~/Music/Music/Artist/Album``, which just looks
broken. So a folder that is *itself* called Music and doesn't already
contain one is treated as the tree, not its parent.
Applied once, at set time, so the answer is persisted and can never flip
later because a subfolder appeared. An "iTunes Media" folder (or any folder
that already has a Music child) is stored unchanged, which leaves existing
libraries exactly where they are.
"""
chosen = Path(chosen).expanduser()
if chosen.name.lower() == "music" and not (chosen / "Music").is_dir():
return chosen.parent
return chosen
def organize_root_for(media_folder) -> Path:
"""The tree files are actually filed into, for preview text."""
return Path(media_folder) / "Music"
def machine_override(config=None) -> str:
config = load_config() if config is None else config
return config.get(OVERRIDE_KEY, "")
def set_machine_override(path: str | None):
"""Record (or clear) this machine's own music folder. Never synced."""
config = load_config()
if path:
config[OVERRIDE_KEY] = str(path)
else:
config.pop(OVERRIDE_KEY, None)
save_config(config)
def prompt_seen(config=None) -> bool:
config = load_config() if config is None else config
return bool(config.get(PROMPT_SEEN_KEY))
def mark_prompt_seen():
"""Remember that this machine has been asked once.
Deliberately in config.json rather than preferences.json: preferences ride
the synced data dir, so a "Not Now" on one machine would silently suppress
the question on the other — but each machine may genuinely need its own
answer.
"""
config = load_config()
config[PROMPT_SEEN_KEY] = True
save_config(config)
+33
View File
@@ -356,13 +356,46 @@ def _merge_play_journal(original_path: Path, conflict_path: Path) -> ConflictSum
if changed else ["No differences needed reconciling."])
_MUSIC_KEYS = ("music_folder", "music_folder_rel", "music_folder_set_at")
def _merge_metadata(original_path: Path, conflict_path: Path) -> ConflictSummary:
# Read both before _keep_newer overwrites one of them.
try:
before = read_json(original_path)
other = read_json(conflict_path)
except (ValueError, OSError):
before = other = None
summary = _keep_newer(original_path, conflict_path)
summary.file, summary.kind = "library_metadata.json", "metadata"
summary.lines = ["Library columns/settings taken from the most recently edited copy."]
# The whole-file pick above goes by mtime, which moves for cosmetic reasons
# (a column resize rewrites this file). The music folder is a deliberate
# setting, so decide it on its own stamp instead — otherwise a resize on one
# machine can silently revert a folder change made on the other.
if before is not None and other is not None:
kept = read_json(original_path)
loser = other if kept.get("music_folder_set_at") == before.get(
"music_folder_set_at") else before
if _newer_stamp(loser.get("music_folder_set_at"),
kept.get("music_folder_set_at")):
for key in _MUSIC_KEYS:
if key in loser:
kept[key] = loser[key]
write_json(original_path, kept)
summary.lines.append(
"Music folder taken from the copy that set it most recently.")
return summary
def _newer_stamp(candidate, current) -> bool:
if not candidate:
return False
return not current or candidate > current
def _keep_newer(original_path: Path, conflict_path: Path) -> ConflictSummary:
took = "this machine"
if conflict_path.stat().st_mtime > original_path.stat().st_mtime:
+17 -1
View File
@@ -54,7 +54,14 @@ def _write_base_play_fields(data: dict, base: dict):
def save_metadata(library: Library, data_dir: Path):
data_dir.mkdir(parents=True, exist_ok=True)
metadata = {
# Both spellings, always. Old code reads the absolute key and behaves
# exactly as it did; new code prefers the relative one. Old code that
# *writes* this file simply drops the new keys, and the reader below
# falls back to the absolute one — so no version combination hard-fails.
"music_folder": library.music_folder,
"music_folder_rel": to_relative(library.music_folder, data_dir)
if library.music_folder else "",
"music_folder_set_at": library.music_folder_set_at,
"import_date": library.import_date,
"track_count": len(library.tracks),
"playlist_count": len(library.playlists),
@@ -90,14 +97,21 @@ def load_library(data_dir: Path) -> Library:
track.location = to_absolute(track.location, data_dir)
tracks[track.track_id] = track
# Load metadata
# Load metadata. The three music_folder fields are read raw and left
# unresolved on purpose — deciding *which* one applies on this machine
# needs the config file too, so it lives in lintunes.music_folder and this
# module stays a dumb (de)serializer.
music_folder = ""
music_folder_rel = ""
music_folder_set_at = None
import_date = None
library_settings = PlaylistSettings()
metadata_path = data_dir / "library_metadata.json"
if metadata_path.exists():
metadata = read_json(metadata_path)
music_folder = metadata.get("music_folder", "")
music_folder_rel = metadata.get("music_folder_rel", "")
music_folder_set_at = metadata.get("music_folder_set_at")
import_date = metadata.get("import_date")
if "library_settings" in metadata:
library_settings = PlaylistSettings.from_dict(metadata["library_settings"])
@@ -115,6 +129,8 @@ def load_library(data_dir: Path) -> Library:
tracks=tracks,
playlists=playlists,
music_folder=music_folder,
music_folder_rel=music_folder_rel,
music_folder_set_at=music_folder_set_at,
import_date=import_date,
library_settings=library_settings,
)
+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()