refrain 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
refrain/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.3"
refrain/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from refrain.app import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
refrain/app.py ADDED
@@ -0,0 +1,409 @@
1
+ """Refrain entry point.
2
+
3
+ Wires together: config, single-instance lock, logging, system tray,
4
+ settings window, and the background daemon. Keeps QApplication alive
5
+ even when the settings window is hidden so the tray + daemon persist.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import logging
12
+ import os
13
+ import shutil
14
+ import signal
15
+ import sys
16
+ import time
17
+ from pathlib import Path
18
+
19
+ from PySide6.QtCore import QObject, QThread, QTimer, QtMsgType, Signal, qInstallMessageHandler
20
+ from PySide6.QtGui import QIcon
21
+ from PySide6.QtWidgets import QApplication, QMessageBox, QSystemTrayIcon
22
+
23
+ from refrain import __version__
24
+ from refrain.autostart import disable as autostart_disable
25
+ from refrain.autostart import enable as autostart_enable
26
+ from refrain.autostart import is_enabled as autostart_is_enabled
27
+ from refrain.config import Config
28
+ from refrain.daemon import Daemon
29
+ from refrain.logging_setup import attach_qt_log_bridge, setup_logging
30
+ from refrain.paths import assets_dir
31
+ from refrain.single_instance import AlreadyRunning
32
+ from refrain.single_instance import acquire as acquire_lock
33
+ from refrain.ui.log_window import LogWindow
34
+ from refrain.ui.settings_window import SettingsWindow
35
+ from refrain.ui.tray import TrayIcon
36
+ from refrain.ui.update_dialog import UpdateDialog
37
+ from refrain.updater import ReleaseInfo, check_latest_release
38
+
39
+ log = logging.getLogger(__name__)
40
+
41
+
42
+ def _parse_args(argv: list[str]) -> argparse.Namespace:
43
+ p = argparse.ArgumentParser(
44
+ prog="refrain",
45
+ description="Discord Rich Presence for Apple Music on Linux",
46
+ )
47
+ p.add_argument("--version", action="version", version=f"refrain {__version__}")
48
+ p.add_argument(
49
+ "--silent",
50
+ action="store_true",
51
+ help="Start minimized to tray; don't open the settings window",
52
+ )
53
+ p.add_argument(
54
+ "--install-desktop",
55
+ action="store_true",
56
+ help="Copy refrain.desktop + icon to ~/.local/share so Refrain shows up "
57
+ "in your application menu, then exit. Useful when installed via pip.",
58
+ )
59
+ p.add_argument(
60
+ "--uninstall-desktop",
61
+ action="store_true",
62
+ help="Remove the files written by --install-desktop, then exit.",
63
+ )
64
+ p.add_argument(
65
+ "--debug",
66
+ action="store_true",
67
+ help="Set log level to DEBUG and open the live-log window on startup.",
68
+ )
69
+ return p.parse_args(argv)
70
+
71
+
72
+ def _user_apps_dir() -> Path:
73
+ return Path.home() / ".local" / "share" / "applications"
74
+
75
+
76
+ def _user_icons_dir() -> Path:
77
+ return Path.home() / ".local" / "share" / "icons" / "hicolor" / "scalable" / "apps"
78
+
79
+
80
+ def install_desktop_files() -> int:
81
+ apps = _user_apps_dir()
82
+ icons = _user_icons_dir()
83
+ apps.mkdir(parents=True, exist_ok=True)
84
+ icons.mkdir(parents=True, exist_ok=True)
85
+
86
+ src_desktop = assets_dir() / "refrain.desktop"
87
+ src_icon = assets_dir() / "icons" / "refrain.svg"
88
+
89
+ dst_desktop = apps / "refrain.desktop"
90
+ dst_icon = icons / "refrain.svg"
91
+
92
+ shutil.copy2(src_desktop, dst_desktop)
93
+ shutil.copy2(src_icon, dst_icon)
94
+
95
+ print("Installed:")
96
+ print(f" {dst_desktop}")
97
+ print(f" {dst_icon}")
98
+ print()
99
+ print("Refrain should now appear in your application menu.")
100
+ print("Run with --uninstall-desktop to remove these files.")
101
+ return 0
102
+
103
+
104
+ def uninstall_desktop_files() -> int:
105
+ removed: list[Path] = []
106
+ for p in (_user_apps_dir() / "refrain.desktop", _user_icons_dir() / "refrain.svg"):
107
+ if p.exists():
108
+ p.unlink()
109
+ removed.append(p)
110
+ if removed:
111
+ print("Removed:")
112
+ for p in removed:
113
+ print(f" {p}")
114
+ else:
115
+ print("Nothing to remove (refrain.desktop and refrain.svg are not installed).")
116
+ return 0
117
+
118
+
119
+ _QT_NOISE_SUBSTRINGS = (
120
+ # Harmless on systems where xdg-desktop-portal isn't running or doesn't
121
+ # know about us yet. Confuses users when they open the live log.
122
+ "Failed to register with host portal",
123
+ )
124
+
125
+ _QT_LEVEL_MAP = {
126
+ QtMsgType.QtDebugMsg: logging.DEBUG,
127
+ QtMsgType.QtInfoMsg: logging.INFO,
128
+ QtMsgType.QtWarningMsg: logging.WARNING,
129
+ QtMsgType.QtCriticalMsg: logging.ERROR,
130
+ QtMsgType.QtFatalMsg: logging.CRITICAL,
131
+ }
132
+
133
+
134
+ def _qt_message_handler(msg_type, _context, message: str) -> None:
135
+ if any(noise in message for noise in _QT_NOISE_SUBSTRINGS):
136
+ return
137
+ logging.getLogger("qt").log(_QT_LEVEL_MAP.get(msg_type, logging.INFO), "%s", message)
138
+
139
+
140
+ def _install_signal_handlers(app: QApplication) -> None:
141
+ """Make Ctrl+C / SIGTERM cleanly quit the Qt event loop."""
142
+ signal.signal(signal.SIGINT, lambda *_: app.quit())
143
+ signal.signal(signal.SIGTERM, lambda *_: app.quit())
144
+ # Qt's event loop blocks Python signal delivery on Linux; a no-op timer
145
+ # wakes Python frequently enough to deliver them.
146
+ timer = QTimer()
147
+ timer.start(500)
148
+ timer.timeout.connect(lambda: None)
149
+ app._refrain_signal_timer = timer # keep a strong reference
150
+
151
+
152
+ def _sync_autostart(config: Config) -> None:
153
+ if config.behavior.autostart and not autostart_is_enabled():
154
+ autostart_enable()
155
+ elif not config.behavior.autostart and autostart_is_enabled():
156
+ autostart_disable()
157
+
158
+
159
+ class _UpdateCheckWorker(QObject):
160
+ """Run check_latest_release on a background QThread."""
161
+
162
+ finished_with_release = Signal(object) # ReleaseInfo | None
163
+
164
+ def run(self) -> None:
165
+ try:
166
+ release = check_latest_release()
167
+ except Exception as e:
168
+ log.debug("Background update check failed: %s", e)
169
+ release = None
170
+ self.finished_with_release.emit(release)
171
+
172
+
173
+ class UpdateOrchestrator(QObject):
174
+ """Coordinates GitHub update checks, tray badge, and the update dialog.
175
+
176
+ The actual HTTP call runs on a worker QThread so the GUI stays responsive.
177
+ """
178
+
179
+ UPDATE_CHECK_COOLDOWN_S = 24 * 3600 # at most once per day on auto-check
180
+
181
+ updateAvailable = Signal(object) # ReleaseInfo
182
+ # Manual checks emit one of these so the user always gets feedback.
183
+ checkUpToDate = Signal(str) # current version string
184
+ checkFailed = Signal(str) # error message
185
+
186
+ def __init__(self, config: Config, parent: QObject | None = None):
187
+ super().__init__(parent)
188
+ self._config = config
189
+ self._latest: ReleaseInfo | None = None
190
+ self._thread: QThread | None = None
191
+ self._worker: _UpdateCheckWorker | None = None
192
+ self._manual = False
193
+
194
+ @property
195
+ def latest(self) -> ReleaseInfo | None:
196
+ return self._latest
197
+
198
+ def maybe_check_on_startup(self) -> None:
199
+ if not self._config.update.auto_check:
200
+ return
201
+ elapsed = time.time() - self._config.update.last_check_ts
202
+ if elapsed < self.UPDATE_CHECK_COOLDOWN_S:
203
+ log.debug("Auto-update check skipped (cooldown, %.0fs since last)", elapsed)
204
+ return
205
+ self.check_now(manual=False)
206
+
207
+ def check_now(self, manual: bool = True) -> None:
208
+ if self._thread is not None:
209
+ log.debug("Update check already in progress")
210
+ return
211
+ self._manual = manual
212
+ self._thread = QThread()
213
+ self._thread.setObjectName("refrain-update-check")
214
+ self._worker = _UpdateCheckWorker()
215
+ self._worker.moveToThread(self._thread)
216
+ self._thread.started.connect(self._worker.run)
217
+ self._worker.finished_with_release.connect(self._on_check_finished)
218
+ self._thread.start()
219
+
220
+ def _on_check_finished(self, release: ReleaseInfo | None) -> None:
221
+ self._config.update.last_check_ts = int(time.time())
222
+ try:
223
+ self._config.save()
224
+ except Exception as e:
225
+ log.debug("Could not persist last_check_ts: %s", e)
226
+
227
+ if self._thread is not None:
228
+ self._thread.quit()
229
+ self._thread.wait(1500)
230
+ self._thread = None
231
+ self._worker = None
232
+
233
+ manual = self._manual
234
+ self._manual = False
235
+
236
+ if release is None:
237
+ log.info("Update check: no release info returned")
238
+ if manual:
239
+ self.checkFailed.emit("Could not reach GitHub. Check your network and try again.")
240
+ return
241
+ if not release.is_newer_than_current:
242
+ log.info("Update check: already on latest (%s)", release.version)
243
+ if manual:
244
+ self.checkUpToDate.emit(__version__)
245
+ return
246
+
247
+ log.info("Update check: %s available (current: %s)", release.version, __version__)
248
+ self._latest = release
249
+ self.updateAvailable.emit(release)
250
+
251
+
252
+ def main() -> int:
253
+ args = _parse_args(sys.argv[1:])
254
+
255
+ if args.install_desktop:
256
+ return install_desktop_files()
257
+ if args.uninstall_desktop:
258
+ return uninstall_desktop_files()
259
+
260
+ config = Config.load()
261
+ log_level = "DEBUG" if args.debug else config.advanced.log_level
262
+ setup_logging(log_level)
263
+ log_bridge = attach_qt_log_bridge()
264
+ qInstallMessageHandler(_qt_message_handler)
265
+ log.info("Refrain %s starting", __version__)
266
+
267
+ app = QApplication(sys.argv)
268
+ app.setApplicationName("Refrain")
269
+ app.setApplicationDisplayName("Refrain")
270
+ app.setApplicationVersion(__version__)
271
+ app.setDesktopFileName("refrain")
272
+ app.setQuitOnLastWindowClosed(False)
273
+
274
+ icon_path = assets_dir() / "icons" / "refrain.svg"
275
+ if icon_path.exists():
276
+ app.setWindowIcon(QIcon(str(icon_path)))
277
+
278
+ try:
279
+ bus_lock = acquire_lock()
280
+ except AlreadyRunning:
281
+ QMessageBox.information(None, "Refrain", "Refrain is already running.")
282
+ return 0
283
+ app._refrain_bus_lock = bus_lock # keep alive for the lifetime of the app
284
+
285
+ if not QSystemTrayIcon.isSystemTrayAvailable():
286
+ QMessageBox.critical(
287
+ None,
288
+ "Refrain",
289
+ "No system tray available.\n\n"
290
+ "On GNOME, install the 'AppIndicator and KStatusNotifierItem' "
291
+ "extension and re-run Refrain.",
292
+ )
293
+ return 1
294
+
295
+ tray = TrayIcon()
296
+ daemon = Daemon(config)
297
+ settings = SettingsWindow(config)
298
+ updater = UpdateOrchestrator(config)
299
+ log_window = LogWindow(log_bridge)
300
+
301
+ daemon.worker.trackChanged.connect(tray.set_track)
302
+ daemon.worker.statusChanged.connect(tray.set_status)
303
+ daemon.worker.progressTick.connect(tray.set_progress)
304
+ daemon.worker.discordConnectionChanged.connect(tray.set_discord_connected)
305
+ tray.settingsRequested.connect(settings.show)
306
+ tray.settingsRequested.connect(settings.raise_)
307
+ tray.settingsRequested.connect(settings.activateWindow)
308
+ tray.playPauseRequested.connect(daemon.worker.control_play_pause)
309
+ tray.nextRequested.connect(daemon.worker.control_next)
310
+ tray.previousRequested.connect(daemon.worker.control_previous)
311
+ tray.quitRequested.connect(app.quit)
312
+ # Two connections: worker.update_config gets queued onto the worker thread,
313
+ # _sync_autostart runs on the main thread (file I/O, OK).
314
+ settings.applied.connect(daemon.worker.update_config)
315
+ settings.applied.connect(_sync_autostart)
316
+
317
+ # Updater wireup — Settings button = manual check (always shows feedback);
318
+ # the auto-check on startup goes through maybe_check_on_startup() which
319
+ # passes manual=False and stays silent on no-update / error.
320
+ settings.checkUpdatesRequested.connect(lambda: updater.check_now(manual=True))
321
+ tray.updateRequested.connect(_open_update_dialog_factory(updater, settings))
322
+ updater.updateAvailable.connect(lambda r: tray.set_update_available(True, r.version))
323
+ updater.updateAvailable.connect(_open_update_dialog_factory(updater, settings))
324
+ updater.checkUpToDate.connect(
325
+ lambda v: QMessageBox.information(
326
+ settings,
327
+ "Refrain — Updates",
328
+ f"You're already on the latest version ({v}).",
329
+ )
330
+ )
331
+ updater.checkFailed.connect(lambda msg: QMessageBox.warning(settings, "Refrain — Updates", msg))
332
+
333
+ # Log-window wireup
334
+ def _show_log() -> None:
335
+ log_window.show()
336
+ log_window.raise_()
337
+ log_window.activateWindow()
338
+
339
+ tray.logRequested.connect(_show_log)
340
+ settings.showLogRequested.connect(_show_log)
341
+
342
+ # Restart wireup — set a flag and quit; main() re-execs after the Qt
343
+ # event loop returns so the daemon, RPC and DBus name release cleanly
344
+ # before the new process starts.
345
+ def _restart() -> None:
346
+ log.info("Restart requested")
347
+ app._refrain_should_restart = True
348
+ app.quit()
349
+
350
+ tray.restartRequested.connect(_restart)
351
+ settings.restartRequested.connect(_restart)
352
+
353
+ _install_signal_handlers(app)
354
+ _sync_autostart(config)
355
+
356
+ daemon.start()
357
+
358
+ if not args.silent:
359
+ settings.show()
360
+
361
+ if args.debug:
362
+ _show_log()
363
+
364
+ # Run the auto-check shortly after the window is up — non-blocking.
365
+ QTimer.singleShot(2000, updater.maybe_check_on_startup)
366
+
367
+ rc = app.exec()
368
+ daemon.stop()
369
+
370
+ if getattr(app, "_refrain_should_restart", False):
371
+ log.info("Re-execing for restart")
372
+ # Drop one-shot CLI flags from the next launch.
373
+ new_argv = [
374
+ arg for arg in sys.argv[1:] if arg not in ("--install-desktop", "--uninstall-desktop")
375
+ ]
376
+ # Pick the right binary to re-exec:
377
+ # - Inside an AppImage, $APPIMAGE is the original .AppImage path
378
+ # (sys.argv[0] points into the AppImage's mount, which exec
379
+ # would resolve correctly but is less stable across mounts).
380
+ # - Otherwise, sys.argv[0] is the entry-point script the user
381
+ # actually launched (venv shim, system /usr/bin/refrain, etc.).
382
+ binary = os.environ.get("APPIMAGE") or sys.argv[0]
383
+ # Release the bus name explicitly before exec so the new process
384
+ # never races with the dying old one for the single-instance lock.
385
+ app._refrain_bus_lock = None
386
+ os.execvp(binary, [binary, *new_argv])
387
+
388
+ log.info("Refrain shutting down with rc=%d", rc)
389
+ return rc
390
+
391
+
392
+ def _open_update_dialog_factory(updater: UpdateOrchestrator, parent_widget):
393
+ def _open(_release=None) -> None:
394
+ release = _release if _release is not None else updater.latest
395
+ if release is None:
396
+ QMessageBox.information(
397
+ parent_widget,
398
+ "Refrain — Updates",
399
+ "No update information available yet. Try again in a moment.",
400
+ )
401
+ return
402
+ dlg = UpdateDialog(release, parent=parent_widget)
403
+ dlg.exec()
404
+
405
+ return _open
406
+
407
+
408
+ if __name__ == "__main__":
409
+ sys.exit(main())
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
2
+ <path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>
3
+ </svg>
@@ -0,0 +1,14 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
2
+ <defs>
3
+ <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0%" stop-color="#FFB347"/>
5
+ <stop offset="100%" stop-color="#E85D04"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect x="0" y="0" width="256" height="256" rx="56" ry="56" fill="url(#bg)"/>
9
+ <g fill="none" stroke="#ffffff" stroke-width="18" stroke-linecap="round" stroke-linejoin="round">
10
+ <path d="M 80 60 L 80 196"/>
11
+ <path d="M 80 60 Q 184 60 184 110 Q 184 156 92 148"/>
12
+ <path d="M 132 148 L 192 200"/>
13
+ </g>
14
+ </svg>
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
2
+ <rect x="6" y="5" width="4" height="14" rx="1" fill="#e8e8e8"/>
3
+ <rect x="14" y="5" width="4" height="14" rx="1" fill="#e8e8e8"/>
4
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
2
+ <path d="M 8 5 L 19 12 L 8 19 Z" fill="#e8e8e8"/>
3
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
2
+ <path d="M 4 12 Q 8 6 12 12 T 20 12" fill="none" stroke="#9a9a9a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
3
+ </svg>
@@ -0,0 +1,14 @@
1
+ [Desktop Entry]
2
+ Type=Application
3
+ Name=Refrain
4
+ GenericName=Apple Music Discord RPC
5
+ GenericName[de_DE]=Apple Music Discord-Status
6
+ Comment=Discord Rich Presence for Apple Music
7
+ Comment[de_DE]=Discord-Status für Apple Music
8
+ Exec=refrain
9
+ Icon=refrain
10
+ Terminal=false
11
+ Categories=Audio;Music;Network;
12
+ Keywords=apple;music;discord;rpc;rich;presence;
13
+ StartupNotify=true
14
+ StartupWMClass=Refrain
refrain/autostart.py ADDED
@@ -0,0 +1,87 @@
1
+ """Toggle XDG autostart entry for Refrain.
2
+
3
+ The Exec= value has to point at *something the desktop session can actually
4
+ launch* — not just `refrain`, because for venv / pip --user / pipx installs
5
+ the bare name isn't on $PATH at session-startup time.
6
+
7
+ Resolution order:
8
+ 1. $APPIMAGE — the .AppImage path, if we were launched from one.
9
+ 2. shutil.which("refrain") — picks up venv shims, /usr/bin, ~/.local/bin.
10
+ 3. sys.argv[0] — whatever launched the current process, as an absolute path.
11
+ 4. `<sys.executable> -m refrain` — last resort if argv[0] isn't a file.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import os
18
+ import shutil
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from refrain.paths import autostart_path
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+ _DESKTOP_ENTRY_TEMPLATE = """\
27
+ [Desktop Entry]
28
+ Type=Application
29
+ Name=Refrain
30
+ GenericName=Apple Music Discord RPC
31
+ Comment=Discord Rich Presence for Apple Music
32
+ Exec={exec_line}
33
+ Icon=refrain
34
+ Terminal=false
35
+ Categories=Audio;Music;Network;
36
+ StartupNotify=false
37
+ X-GNOME-Autostart-enabled=true
38
+ """
39
+
40
+
41
+ def _quote(path: str) -> str:
42
+ # Per the Desktop Entry spec, paths with spaces or special chars must be
43
+ # double-quoted in Exec=. Escape inner double quotes and backslashes.
44
+ if any(c in path for c in ' \t\n"\\$`'):
45
+ escaped = path.replace("\\", "\\\\").replace('"', '\\"')
46
+ return f'"{escaped}"'
47
+ return path
48
+
49
+
50
+ def _exec_line() -> str:
51
+ appimage = os.environ.get("APPIMAGE")
52
+ if appimage and Path(appimage).is_file():
53
+ return f"{_quote(appimage)} --silent"
54
+
55
+ on_path = shutil.which("refrain")
56
+ if on_path:
57
+ return f"{_quote(on_path)} --silent"
58
+
59
+ argv0 = sys.argv[0] if sys.argv else ""
60
+ if argv0:
61
+ argv0_abs = str(Path(argv0).resolve())
62
+ if Path(argv0_abs).is_file():
63
+ return f"{_quote(argv0_abs)} --silent"
64
+
65
+ return f"{_quote(sys.executable)} -m refrain --silent"
66
+
67
+
68
+ def _desktop_entry() -> str:
69
+ return _DESKTOP_ENTRY_TEMPLATE.format(exec_line=_exec_line())
70
+
71
+
72
+ def is_enabled() -> bool:
73
+ return autostart_path().exists()
74
+
75
+
76
+ def enable() -> None:
77
+ p = autostart_path()
78
+ p.parent.mkdir(parents=True, exist_ok=True)
79
+ p.write_text(_desktop_entry(), encoding="utf-8")
80
+ log.info("Autostart enabled at %s", p)
81
+
82
+
83
+ def disable() -> None:
84
+ p = autostart_path()
85
+ if p.exists():
86
+ p.unlink()
87
+ log.info("Autostart disabled (removed %s)", p)