diff --git a/lintunes/__init__.py b/lintunes/__init__.py index e69de29..a8c01dc 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -0,0 +1,3 @@ +"""LinTunes — iTunes-style music library manager and player for Linux.""" + +__version__ = "0.1.0" diff --git a/lintunes/gui/main_window.py b/lintunes/gui/main_window.py index 67466c3..bb343d4 100644 --- a/lintunes/gui/main_window.py +++ b/lintunes/gui/main_window.py @@ -21,6 +21,8 @@ from lintunes.gui.transport import TransportBar from lintunes.gui.info_dialog import InfoDialog from lintunes.gui.preferences_dialog import PreferencesDialog from lintunes.gui.track_table import format_total_time +from lintunes.gui.version_button import VersionButton +from lintunes.updater import Updater def album_tracks(library, artist: str, album: str) -> list: @@ -81,11 +83,23 @@ class MainWindow(QMainWindow): self._content.addWidget(self._playlist_view) self._content.setCurrentWidget(self._library_view) - # Bottom status bar: a permanent, centered totals readout (transient - # import/scrobble/error messages still use the left showMessage area). + # Bottom status bar, left to right: version/self-update button, then a + # centered totals readout. Both are non-permanent widgets, so a + # transient showMessage (import/scrobble/error/update text) replaces + # them for its duration and they come back — a permanent widget with + # 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._updater = Updater(self) + self._version_button = VersionButton(self._updater) + self.statusBar().addWidget(self._version_button) self._totals_label = QLabel() self._totals_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.statusBar().addPermanentWidget(self._totals_label, 1) + self.statusBar().addWidget(self._totals_label, 1) + self._version_button.status_message.connect( + lambda msg: self.statusBar().showMessage(msg, 8000)) + self._updater.update_applied.connect(self._restart_for_update) + self._updater.start_checking() # Wiring self._transport.play_clicked.connect(self.play_pause) @@ -127,6 +141,12 @@ class MainWindow(QMainWindow): self.apply_ui_metrics() self._update_totals() + def _restart_for_update(self): + # The pull already succeeded; quit through the normal path (flushes + # the library, tears down the player) and let main() re-exec us. + self.restart_requested = True + QApplication.instance().quit() + # ---- status bar totals ---- def _update_totals(self): @@ -145,6 +165,7 @@ class MainWindow(QMainWindow): theme.apply_theme(QApplication.instance(), self._prefs) self.apply_ui_metrics() self._transport.refresh_theme() + self._version_button.refresh_theme() def apply_ui_metrics(self): metrics = theme.scale_metrics(self._prefs) diff --git a/lintunes/gui/version_button.py b/lintunes/gui/version_button.py new file mode 100644 index 0000000..8821c2d --- /dev/null +++ b/lintunes/gui/version_button.py @@ -0,0 +1,70 @@ +"""The little version readout in the status bar's bottom-left corner. + +Normally an inert label ("v0.1.0"; tooltip shows the git commit so identical +versions on different machines are still distinguishable). When the Updater +finds upstream commits it grows a "*" and becomes a button: click → confirm → +git pull → the main window restarts the app on `update_applied`. +""" + +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QPalette +from PyQt6.QtWidgets import QMessageBox, QPushButton + +from lintunes import __version__ + + +class VersionButton(QPushButton): + status_message = pyqtSignal(str) + + def __init__(self, updater, parent=None): + super().__init__(parent) + self._updater = updater + self._update_ready = False + self.setFlat(True) + self.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.refresh_theme() + self.setText(f"v{__version__}") + commit = updater.short_hash() + self._base_tooltip = (f"LinTunes v{__version__} ({commit})" if commit + else f"LinTunes v{__version__}") + self.setToolTip(self._base_tooltip) + + updater.update_available.connect(self._on_update_available) + updater.update_failed.connect(self._on_update_failed) + self.clicked.connect(self._on_clicked) + + def refresh_theme(self): + # Subtle but readable: the theme's text color at ~55% opacity + # (palette(mid) is too close to the light backgrounds to read). + text = self.palette().color(QPalette.ColorRole.WindowText) + self.setStyleSheet( + "QPushButton { border: none; padding: 0 6px; " + f"color: rgba({text.red()}, {text.green()}, {text.blue()}, 140); }}") + + def _on_update_available(self, commits: int): + self._update_ready = True + self.setText(f"v{__version__}*") + noun = "commit" if commits == 1 else "commits" + self.setToolTip(f"Update available ({commits} {noun}) — " + "click to update and restart") + self.setCursor(Qt.CursorShape.PointingHandCursor) + + def _on_clicked(self): + if not self._update_ready or not self.isEnabled(): + return + if not self._confirm(): + return + self.setEnabled(False) + self.setText("updating…") + self._updater.update_async() + + def _confirm(self) -> bool: + answer = QMessageBox.question( + self.window(), "Update LinTunes", + "Pull the latest version and restart LinTunes?") + return answer == QMessageBox.StandardButton.Yes + + def _on_update_failed(self, message: str): + self.setEnabled(True) + self.setText(f"v{__version__}*") + self.status_message.emit(f"Update failed: {message}") diff --git a/lintunes/main.py b/lintunes/main.py index 11c8391..bf3aa6d 100644 --- a/lintunes/main.py +++ b/lintunes/main.py @@ -1,4 +1,5 @@ import argparse +import os import sys from pathlib import Path @@ -58,7 +59,15 @@ def main(): if args.import_file: run_import(Path(args.import_file), music_root, data_dir) else: - run_gui(data_dir, [Path(f) for f in args.files], qt_args) + 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): @@ -93,7 +102,8 @@ def run_import(xml_path: Path, music_root: str | None, data_dir: Path): print("Import complete!") -def run_gui(data_dir: Path, files: list[Path], qt_args: list[str] | None = None): +def run_gui(data_dir: Path, files: list[Path], + qt_args: list[str] | None = None) -> bool: import logging logging.basicConfig(level=logging.INFO) @@ -147,7 +157,10 @@ def run_gui(data_dir: Path, files: list[Path], qt_args: list[str] | None = None) window.show_conflict_summary(startup_conflicts) if files: window.import_files(files) - sys.exit(app.exec()) + 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): diff --git a/lintunes/updater.py b/lintunes/updater.py new file mode 100644 index 0000000..e86c7c9 --- /dev/null +++ b/lintunes/updater.py @@ -0,0 +1,108 @@ +"""Self-update via git. + +LinTunes runs straight from its git checkout (editable install), so an update +is just a fast-forward `git pull` followed by an app relaunch. The Updater +periodically fetches the checkout's upstream and reports how many commits +behind we are; `update_async()` applies them with `--ff-only` (never a merge, +never a conflicted tree). Network git calls run on daemon threads — same +pattern as lastfm.py — and report back through Qt signals. + +When the package isn't running from a git checkout (or has no upstream), the +updater disables itself silently and the version display is just a label. +""" + +import subprocess +import threading +from pathlib import Path + +from PyQt6.QtCore import QObject, QTimer, pyqtSignal + +FIRST_CHECK_MS = 10 * 1000 # shortly after launch, off the startup path +RECHECK_INTERVAL_MS = 4 * 3600 * 1000 # then every 4 hours + + +class Updater(QObject): + update_available = pyqtSignal(int) # commits behind upstream + update_applied = pyqtSignal() # pull succeeded; caller restarts the app + update_failed = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self._root = str(Path(__file__).resolve().parents[1]) + self._busy = False + self._timer = None + self.enabled = (self._git("rev-parse", "--show-toplevel") is not None + and self._git("rev-parse", "--abbrev-ref", "@{upstream}") + is not None) + + def short_hash(self) -> str | None: + """The checkout's current commit, for telling builds apart.""" + return self._git("rev-parse", "--short", "HEAD") + + def start_checking(self): + """First check shortly after launch, then every few hours.""" + if not self.enabled: + return + QTimer.singleShot(FIRST_CHECK_MS, self.check_async) + self._timer = QTimer(self) + self._timer.setInterval(RECHECK_INTERVAL_MS) + self._timer.timeout.connect(self.check_async) + self._timer.start() + + def check_async(self): + if not self.enabled or self._busy: + return + self._busy = True + threading.Thread(target=self._check, daemon=True).start() + + def update_async(self): + if not self.enabled or self._busy: + return + self._busy = True + threading.Thread(target=self._update, daemon=True).start() + + # ---- internals (run on daemon threads) ---- + + def _check(self): + try: + if self._git("fetch", "--quiet", timeout=60) is None: + return # offline / host unreachable — perfectly normal, stay quiet + behind = self._git("rev-list", "--count", "HEAD..@{upstream}") + if behind and behind.isdigit() and int(behind) > 0: + self.update_available.emit(int(behind)) + finally: + self._busy = False + + def _update(self): + try: + proc = subprocess.run( + ["git", "-C", self._root, "pull", "--ff-only", "--quiet"], + capture_output=True, text=True, timeout=120) + if proc.returncode == 0: + self.update_applied.emit() + else: + lines = [l.strip() for l in + (proc.stderr or proc.stdout).strip().splitlines() + if l.strip()] + # git's actual reason is the error:/fatal: line, not the + # trailing "Aborting" + message = next( + (l for l in lines if l.startswith(("error:", "fatal:"))), + lines[-1] if lines else "git pull failed") + self.update_failed.emit(message) + except Exception as e: + self.update_failed.emit(str(e)) + finally: + self._busy = False + + def _git(self, *args, timeout=15) -> str | None: + """Run git in the repo root; stripped stdout, or None on any failure.""" + try: + proc = subprocess.run(["git", "-C", self._root, *args], + capture_output=True, text=True, + timeout=timeout) + except Exception: + return None + if proc.returncode != 0: + return None + return proc.stdout.strip() diff --git a/setup.py b/setup.py index 0eb5d99..9b53730 100644 --- a/setup.py +++ b/setup.py @@ -1,8 +1,16 @@ +import re +from pathlib import Path + from setuptools import setup, find_packages +# Single source of truth for the version is lintunes/__init__.py; read it +# textually so setup.py doesn't import the package (and its Qt deps). +_init = (Path(__file__).parent / "lintunes" / "__init__.py").read_text() +_version = re.search(r'^__version__ = "([^"]+)"', _init, re.MULTILINE)[1] + setup( name="lintunes", - version="0.1.0", + version=_version, packages=find_packages(), install_requires=[ "PyQt6>=6.8.0", # QAudioBufferOutput (visualizer) needs Qt 6.8+ diff --git a/tests/test_round19.py b/tests/test_round19.py new file mode 100644 index 0000000..08bf2ad --- /dev/null +++ b/tests/test_round19.py @@ -0,0 +1,195 @@ +"""Round 19: version display + git self-update (updater, version button).""" + +import subprocess + +import pytest +from PyQt6.QtCore import QObject, pyqtSignal + +from lintunes import __version__ +from lintunes.updater import Updater + + +class FakeCompleted: + def __init__(self, returncode=0, stdout="", stderr=""): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +def make_updater(monkeypatch, git_responses): + """An Updater whose git calls are served from `git_responses`: + {subcommand: FakeCompleted or Exception}. Unlisted subcommands succeed + with empty output.""" + + def fake_run(cmd, **kwargs): + assert cmd[0] == "git" and cmd[1] == "-C" + sub = cmd[3] + response = git_responses.get(sub, FakeCompleted()) + if isinstance(response, Exception): + raise response + return response + + monkeypatch.setattr(subprocess, "run", fake_run) + return Updater() + + +def collect(signal): + got = [] + signal.connect(lambda *args: got.append(args[0] if args else None)) + return got + + +def test_updater_disabled_outside_git_checkout(qapp, monkeypatch): + updater = make_updater(monkeypatch, { + "rev-parse": FakeCompleted(returncode=128, stderr="not a git repo")}) + assert not updater.enabled + # disabled updater never spawns work + updater.check_async() + updater.update_async() + + +def test_check_emits_update_available(qapp, monkeypatch): + updater = make_updater(monkeypatch, { + "rev-parse": FakeCompleted(stdout="/repo\n"), + "rev-list": FakeCompleted(stdout="3\n"), + }) + assert updater.enabled + got = collect(updater.update_available) + updater._check() # run synchronously; check_async just threads this + assert got == [3] + + +def test_check_quiet_when_up_to_date(qapp, monkeypatch): + updater = make_updater(monkeypatch, { + "rev-list": FakeCompleted(stdout="0\n")}) + got = collect(updater.update_available) + updater._check() + assert got == [] + + +def test_check_quiet_when_fetch_fails(qapp, monkeypatch): + updater = make_updater(monkeypatch, { + "fetch": FakeCompleted(returncode=128, stderr="ssh: no route to host"), + "rev-list": FakeCompleted(stdout="3\n"), + }) + got = collect(updater.update_available) + updater._check() + assert got == [] # offline is normal — no signal, no error + + +def test_update_applied_on_clean_pull(qapp, monkeypatch): + updater = make_updater(monkeypatch, {"pull": FakeCompleted(returncode=0)}) + applied = collect(updater.update_applied) + failed = collect(updater.update_failed) + updater._update() + assert len(applied) == 1 + assert failed == [] + + +def test_update_failed_surfaces_git_error(qapp, monkeypatch): + # The message is the error:/fatal: line, not git's trailing "Aborting" + updater = make_updater(monkeypatch, { + "pull": FakeCompleted(returncode=1, + stderr="error: Your local changes to the " + "following files would be overwritten " + "by merge:\n\ttasks-done.md\n" + "Aborting")}) + applied = collect(updater.update_applied) + failed = collect(updater.update_failed) + updater._update() + assert applied == [] + assert failed == ["error: Your local changes to the following files " + "would be overwritten by merge:"] + + +def test_short_hash(qapp, monkeypatch): + updater = make_updater(monkeypatch, { + "rev-parse": FakeCompleted(stdout="abc1234\n")}) + assert updater.short_hash() == "abc1234" + + +# ---- version button ---- + +class FakeUpdater(QObject): # QObject with the Updater's signal surface + update_available = pyqtSignal(int) + update_applied = pyqtSignal() + update_failed = pyqtSignal(str) + + def __init__(self): + super().__init__() + self.enabled = True + self.update_calls = 0 + + def short_hash(self): + return "abc1234" + + def update_async(self): + self.update_calls += 1 + + +@pytest.fixture +def button(qapp): + from lintunes.gui.version_button import VersionButton + fake = FakeUpdater() + return VersionButton(fake), fake + + +def test_button_shows_version_and_hash(button): + btn, fake = button + assert btn.text() == f"v{__version__}" + assert "abc1234" in btn.toolTip() + + +def test_button_star_on_update_available(button): + btn, fake = button + fake.update_available.emit(2) + assert btn.text() == f"v{__version__}*" + assert "click to update" in btn.toolTip() + + +def test_click_without_update_does_nothing(button): + btn, fake = button + btn._on_clicked() + assert fake.update_calls == 0 + + +def test_click_with_update_confirms_then_pulls(button, monkeypatch): + btn, fake = button + fake.update_available.emit(1) + monkeypatch.setattr(btn, "_confirm", lambda: True) + btn._on_clicked() + assert fake.update_calls == 1 + assert btn.text() == "updating…" + assert not btn.isEnabled() + + +def test_click_with_update_declined(button, monkeypatch): + btn, fake = button + fake.update_available.emit(1) + monkeypatch.setattr(btn, "_confirm", lambda: False) + btn._on_clicked() + assert fake.update_calls == 0 + assert btn.isEnabled() + + +def test_failed_update_reenables_button(button, monkeypatch): + btn, fake = button + fake.update_available.emit(1) + monkeypatch.setattr(btn, "_confirm", lambda: True) + btn._on_clicked() + messages = collect(btn.status_message) + fake.update_failed.emit("fatal: no route to host") + assert btn.isEnabled() + assert btn.text() == f"v{__version__}*" + assert messages == ["Update failed: fatal: no route to host"] + + +def test_version_matches_setup_py(): + import re + from pathlib import Path + setup_py = Path(__file__).parents[1] / "setup.py" + # setup.py reads the version from lintunes/__init__.py textually + assert "__init__.py" in setup_py.read_text() + init = Path(__file__).parents[1] / "lintunes" / "__init__.py" + found = re.search(r'^__version__ = "([^"]+)"', init.read_text(), re.M) + assert found and found[1] == __version__