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.
216 lines
8.5 KiB
Python
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()
|