Files
lintunes/lintunes/main.py
T
trav e69917a290 v0.7.0: startup speed — the 21k-track library stops freezing
Three reported symptoms, one shape: every operation was whole-library,
whole-file, on the Qt main thread. Measured on the real library (21,482
tracks, 506 playlists, 15 MB library.json).

The track table sorted through a QSortFilterProxyModel, which asks data()
for a value on every comparison — 580k Python round trips, 8.5 s per table
load, paid again on every reload. TrackTableModel now keeps _tracks in
canonical (playlist) order plus an _order index list and sorts a key list
computed once per track. Nothing used the proxy's filtering. Sorting by #
is the identity order, so "source row" still means "playlist position" for
drag-reorder. 8.5 s -> 0.13 s; MainWindow() 18.1 s -> 0.79 s.

Smart rules compile to closures once per evaluate() instead of being
re-dispatched per track — the operator lookup, casefolding the query and
parsing the rule's own date constants all leave the 21k-iteration loop
(_match_date was re-parsing its own constant 21,482 times per rule).
Verified identical membership against the old evaluator on all 20 real
smart playlists. 2.54 s -> 0.72 s, and it now runs after the first paint.

Also: _reconcile_track skips two to_dict() round trips per unchanged track
(MERGEABLE_FIELDS in the resolver is the single source of truth for what a
merge touches); no git subprocess on the startup path (Updater.enabled is
probed lazily on the background thread, version-button tooltip deferred);
and .resolved/ is pruned to the newest 10 snapshots — it had reached 1.7 GB
across 73 snapshots inside the Syncthing share.

Time to interactive window ~15 s -> 3.2 s. The mid-session freeze when
Syncthing delivers a change — a full reload + recompute + table rebuild
with the window already on screen, which is what GNOME was offering to
force-quit — ~16 s -> 3.8 s.

The merge rework itself (playlist ordering, per-machine play journal,
quieting the dialog) is queued in TASKS.md as Round 36.
2026-08-19 17:09:07 -04:00

216 lines
8.5 KiB
Python

import argparse
import os
import sys
from pathlib import Path
from lintunes.config import load_config, save_config
def main():
parser = argparse.ArgumentParser(
prog="lintunes",
description="LinTunes — iTunes library manager for Linux",
)
parser.add_argument(
"--import-xml",
dest="import_file",
metavar="XML_FILE",
help="Import an iTunes Library XML file and exit",
)
parser.add_argument(
"--music-root",
help="Local directory corresponding to iTunes' Music Folder "
"(the 'iTunes Media' directory)",
)
parser.add_argument(
"--data-dir",
help="Directory holding the lintunes library JSON files",
)
parser.add_argument(
"--save-config",
action="store_true",
help="Persist --music-root/--data-dir as defaults",
)
parser.add_argument(
"files",
nargs="*",
help="Audio files to import into the library on startup",
)
# Let Qt flags like `-platform xcb` pass through to QApplication
args, qt_args = parser.parse_known_args()
config = load_config()
music_root = args.music_root or config.get("music_root")
data_dir = args.data_dir or config.get("data_dir")
if args.save_config and (args.music_root or args.data_dir):
config.update({k: v for k, v in
(("music_root", args.music_root), ("data_dir", args.data_dir))
if v})
save_config(config)
print(f"Saved config: {config}")
if not data_dir:
print("Error: no data directory configured.\n"
"Run with --data-dir DIR (add --save-config to remember it).")
sys.exit(1)
data_dir = Path(data_dir)
if args.import_file:
run_import(Path(args.import_file), music_root, data_dir)
else:
if run_gui(data_dir, [Path(f) for f in args.files], qt_args):
# A self-update just pulled new code; replace this process with a
# fresh launch running the updated source. -m works whether we
# started as `lintunes` or `python -m lintunes.main`. Positional
# files are dropped — they were imported on the first launch and
# would be re-imported (duplicated) on every restart otherwise.
argv = [a for a in sys.argv[1:] if a not in args.files]
os.execv(sys.executable,
[sys.executable, "-m", "lintunes.main", *argv])
def run_import(xml_path: Path, music_root: str | None, data_dir: Path):
from lintunes.importers.itunes_importer import import_itunes_xml
from lintunes.storage.json_storage import save_library
if not xml_path.exists():
print(f"Error: File not found: {xml_path}")
sys.exit(1)
if not music_root:
print("Error: --music-root is required for import "
"(the local path of iTunes' 'iTunes Media' folder)")
sys.exit(1)
print(f"Importing from {xml_path}...")
library, report = import_itunes_xml(xml_path, Path(music_root))
print(report.summary())
if report.missing_files:
missing_log = data_dir / "import_missing_files.txt"
data_dir.mkdir(parents=True, exist_ok=True)
missing_log.write_text("\n".join(report.missing_files), encoding="utf-8")
print(f"Missing file list written to {missing_log}")
if report.unmapped_locations:
unmapped_log = data_dir / "import_unmapped_locations.txt"
data_dir.mkdir(parents=True, exist_ok=True)
unmapped_log.write_text("\n".join(report.unmapped_locations), encoding="utf-8")
print(f"Unmapped location list written to {unmapped_log}")
print(f"Saving library to {data_dir}...")
save_library(library, data_dir)
print("Import complete!")
# Long enough for the first paint to land before the smart recompute runs.
SMART_RECOMPUTE_DELAY_MS = 50
def run_gui(data_dir: Path, files: list[Path],
qt_args: list[str] | None = None) -> bool:
import logging
logging.basicConfig(level=logging.INFO)
from PyQt6.QtCore import QTimer
from PyQt6.QtWidgets import QApplication, QMessageBox, QFontDialog
from lintunes import theme
from lintunes.storage.json_storage import load_library
from lintunes.storage.conflict_resolver import resolve_conflicts
from lintunes.library_manager import LibraryManager
from lintunes.preferences import Preferences
from lintunes.lastfm import LastFm
from lintunes.gui.main_window import MainWindow
from lintunes.gui.track_table import ClickToJumpScrollStyle
from lintunes.mpris import setup_mpris
from lintunes.sync_watcher import LibrarySyncWatcher
startup_conflicts = resolve_conflicts(data_dir)
library = load_library(data_dir)
app = QApplication([sys.argv[0]] + (qt_args or []))
app.setApplicationName("LinTunes")
app.setDesktopFileName("lintunes")
# Click anywhere on a scrollbar trough to jump there, not page a step at a
# time. Set app-wide (before apply_theme's stylesheet wraps the style) so
# the hint is honored everywhere, including the track list and sidebar.
app.setStyle(ClickToJumpScrollStyle())
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)
app.installEventFilter(window)
mpris = setup_mpris(window.player, window) # noqa: F841 — keep alive
# Tear the multimedia pipeline down before Qt destroys objects (exit
# segfault in the FFmpeg backend otherwise); shutdown() is idempotent so
# the closeEvent path double-calling it is fine.
app.aboutToQuit.connect(window.player.shutdown)
app.aboutToQuit.connect(manager.flush)
# Watch the data dir so another machine's synced-in changes appear live.
watcher = LibrarySyncWatcher(data_dir, window) # noqa: F841 — kept alive by parent
watcher.changed.connect(manager.check_for_external_changes)
window.show()
# 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
# it inline left the compositor without a responsive window.
QTimer.singleShot(SMART_RECOMPUTE_DELAY_MS, manager.recompute_all_smart)
if startup_conflicts:
window.show_conflict_summary(startup_conflicts)
if files:
window.import_files(files)
exit_code = app.exec()
if window.restart_requested:
return True # caller re-execs the updated code
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()