refrain 0.2.5__tar.gz → 0.2.7__tar.gz

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.
Files changed (128) hide show
  1. {refrain-0.2.5 → refrain-0.2.7}/.github/workflows/release.yml +29 -1
  2. {refrain-0.2.5 → refrain-0.2.7}/CHANGELOG.md +229 -2
  3. {refrain-0.2.5 → refrain-0.2.7}/PKG-INFO +11 -2
  4. {refrain-0.2.5 → refrain-0.2.7}/README.md +10 -1
  5. {refrain-0.2.5 → refrain-0.2.7}/ROADMAP.md +88 -5
  6. {refrain-0.2.5 → refrain-0.2.7}/docs/screenshots/README.md +4 -2
  7. refrain-0.2.7/docs/screenshots/live-log.png +0 -0
  8. {refrain-0.2.5 → refrain-0.2.7}/docs/screenshots/settings-advanced.png +0 -0
  9. {refrain-0.2.5 → refrain-0.2.7}/docs/screenshots/settings-general.png +0 -0
  10. {refrain-0.2.5 → refrain-0.2.7}/docs/screenshots/settings-sources.png +0 -0
  11. refrain-0.2.7/docs/screenshots/settings-updates.png +0 -0
  12. refrain-0.2.7/docs/screenshots/tray-menu.png +0 -0
  13. refrain-0.2.7/docs/screenshots/update-dialog.png +0 -0
  14. {refrain-0.2.5 → refrain-0.2.7}/packaging/appimage/AppImageBuilder.yml +1 -1
  15. {refrain-0.2.5 → refrain-0.2.7}/packaging/aur/refrain/.SRCINFO +3 -3
  16. {refrain-0.2.5 → refrain-0.2.7}/packaging/aur/refrain/PKGBUILD +2 -2
  17. {refrain-0.2.5 → refrain-0.2.7}/packaging/flatpak/io.github.Rockykln.Refrain.metainfo.xml +32 -0
  18. {refrain-0.2.5 → refrain-0.2.7}/pyproject.toml +1 -1
  19. refrain-0.2.7/src/refrain/__init__.py +1 -0
  20. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/app.py +98 -16
  21. refrain-0.2.7/src/refrain/assets/icons/menu-quit.svg +3 -0
  22. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/autostart.py +24 -6
  23. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/config.py +112 -8
  24. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/cover_art.py +39 -11
  25. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/cover_fetcher.py +28 -10
  26. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/discord_rpc.py +24 -1
  27. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/i18n/refrain_de.qm +0 -0
  28. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/i18n/refrain_de.ts +14 -14
  29. refrain-0.2.7/src/refrain/i18n/refrain_es.qm +0 -0
  30. refrain-0.2.7/src/refrain/i18n/refrain_es.ts +673 -0
  31. refrain-0.2.7/src/refrain/i18n/refrain_fr.qm +0 -0
  32. refrain-0.2.7/src/refrain/i18n/refrain_fr.ts +673 -0
  33. refrain-0.2.7/src/refrain/i18n/refrain_it.qm +0 -0
  34. refrain-0.2.7/src/refrain/i18n/refrain_it.ts +673 -0
  35. refrain-0.2.7/src/refrain/i18n/refrain_ja.qm +0 -0
  36. refrain-0.2.7/src/refrain/i18n/refrain_ja.ts +673 -0
  37. refrain-0.2.7/src/refrain/i18n/refrain_pl.qm +0 -0
  38. refrain-0.2.7/src/refrain/i18n/refrain_pl.ts +673 -0
  39. refrain-0.2.7/src/refrain/i18n/refrain_pt.qm +0 -0
  40. refrain-0.2.7/src/refrain/i18n/refrain_pt.ts +673 -0
  41. refrain-0.2.7/src/refrain/i18n/refrain_ru.qm +0 -0
  42. refrain-0.2.7/src/refrain/i18n/refrain_ru.ts +673 -0
  43. refrain-0.2.7/src/refrain/i18n/refrain_zh_CN.qm +0 -0
  44. refrain-0.2.7/src/refrain/i18n/refrain_zh_CN.ts +673 -0
  45. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/logging_setup.py +6 -1
  46. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/single_instance.py +18 -3
  47. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/ui/settings_window.py +39 -7
  48. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/ui/tray.py +70 -15
  49. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/ui/update_dialog.py +5 -1
  50. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/updater.py +47 -2
  51. {refrain-0.2.5 → refrain-0.2.7}/tests/test_config.py +74 -0
  52. refrain-0.2.5/docs/screenshots/live-log.png +0 -0
  53. refrain-0.2.5/docs/screenshots/settings-updates.png +0 -0
  54. refrain-0.2.5/docs/screenshots/tray-menu.png +0 -0
  55. refrain-0.2.5/docs/screenshots/update-dialog.png +0 -0
  56. refrain-0.2.5/src/refrain/__init__.py +0 -1
  57. {refrain-0.2.5 → refrain-0.2.7}/.editorconfig +0 -0
  58. {refrain-0.2.5 → refrain-0.2.7}/.gitattributes +0 -0
  59. {refrain-0.2.5 → refrain-0.2.7}/.github/FUNDING.yml +0 -0
  60. {refrain-0.2.5 → refrain-0.2.7}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  61. {refrain-0.2.5 → refrain-0.2.7}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  62. {refrain-0.2.5 → refrain-0.2.7}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  63. {refrain-0.2.5 → refrain-0.2.7}/.github/codeql/codeql-config.yml +0 -0
  64. {refrain-0.2.5 → refrain-0.2.7}/.github/dependabot.yml +0 -0
  65. {refrain-0.2.5 → refrain-0.2.7}/.github/pull_request_template.md +0 -0
  66. {refrain-0.2.5 → refrain-0.2.7}/.github/workflows/codeql.yml +0 -0
  67. {refrain-0.2.5 → refrain-0.2.7}/.github/workflows/security.yml +0 -0
  68. {refrain-0.2.5 → refrain-0.2.7}/.github/workflows/tests.yml +0 -0
  69. {refrain-0.2.5 → refrain-0.2.7}/.gitignore +0 -0
  70. {refrain-0.2.5 → refrain-0.2.7}/CODE_OF_CONDUCT.md +0 -0
  71. {refrain-0.2.5 → refrain-0.2.7}/CONTRIBUTING.md +0 -0
  72. {refrain-0.2.5 → refrain-0.2.7}/LICENSE +0 -0
  73. {refrain-0.2.5 → refrain-0.2.7}/Makefile +0 -0
  74. {refrain-0.2.5 → refrain-0.2.7}/SECURITY.md +0 -0
  75. {refrain-0.2.5 → refrain-0.2.7}/docs/architecture.md +0 -0
  76. {refrain-0.2.5 → refrain-0.2.7}/docs/bluetooth.md +0 -0
  77. {refrain-0.2.5 → refrain-0.2.7}/docs/faq.md +0 -0
  78. {refrain-0.2.5 → refrain-0.2.7}/docs/screenshots/discord-rpc.png +0 -0
  79. {refrain-0.2.5 → refrain-0.2.7}/docs/screenshots/notification.png +0 -0
  80. {refrain-0.2.5 → refrain-0.2.7}/docs/screenshots/welcome.png +0 -0
  81. {refrain-0.2.5 → refrain-0.2.7}/docs/test-matrix.md +0 -0
  82. {refrain-0.2.5 → refrain-0.2.7}/packaging/README.md +0 -0
  83. {refrain-0.2.5 → refrain-0.2.7}/packaging/aur/refrain-git/PKGBUILD +0 -0
  84. {refrain-0.2.5 → refrain-0.2.7}/packaging/flatpak/io.github.Rockykln.Refrain.desktop +0 -0
  85. {refrain-0.2.5 → refrain-0.2.7}/packaging/flatpak/io.github.Rockykln.Refrain.yaml +0 -0
  86. {refrain-0.2.5 → refrain-0.2.7}/packaging/flatpak/python-deps.json +0 -0
  87. {refrain-0.2.5 → refrain-0.2.7}/requirements-dev.txt +0 -0
  88. {refrain-0.2.5 → refrain-0.2.7}/requirements.txt +0 -0
  89. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/__main__.py +0 -0
  90. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/assets/icons/github-mark.svg +0 -0
  91. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/assets/icons/menu-update.svg +0 -0
  92. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/assets/icons/refrain.png +0 -0
  93. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/assets/icons/refrain.svg +0 -0
  94. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/assets/icons/tray-paused-dark.svg +0 -0
  95. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/assets/icons/tray-paused.svg +0 -0
  96. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/assets/icons/tray-playing-dark.svg +0 -0
  97. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/assets/icons/tray-playing.svg +0 -0
  98. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/assets/icons/tray-stopped-dark.svg +0 -0
  99. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/assets/icons/tray-stopped.svg +0 -0
  100. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/assets/refrain.desktop +0 -0
  101. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/daemon.py +0 -0
  102. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/paths.py +0 -0
  103. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/sources/__init__.py +0 -0
  104. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/sources/base.py +0 -0
  105. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/sources/bluetooth.py +0 -0
  106. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/sources/mpris.py +0 -0
  107. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/sources/mpris_server.py +0 -0
  108. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/timing.py +0 -0
  109. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/ui/__init__.py +0 -0
  110. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/ui/log_window.py +0 -0
  111. {refrain-0.2.5 → refrain-0.2.7}/src/refrain/ui/welcome_dialog.py +0 -0
  112. {refrain-0.2.5 → refrain-0.2.7}/tests/__init__.py +0 -0
  113. {refrain-0.2.5 → refrain-0.2.7}/tests/conftest.py +0 -0
  114. {refrain-0.2.5 → refrain-0.2.7}/tests/test_autostart.py +0 -0
  115. {refrain-0.2.5 → refrain-0.2.7}/tests/test_cover_art.py +0 -0
  116. {refrain-0.2.5 → refrain-0.2.7}/tests/test_cover_fetcher.py +0 -0
  117. {refrain-0.2.5 → refrain-0.2.7}/tests/test_daemon_album_format.py +0 -0
  118. {refrain-0.2.5 → refrain-0.2.7}/tests/test_daemon_idle.py +0 -0
  119. {refrain-0.2.5 → refrain-0.2.7}/tests/test_discord_listening.py +0 -0
  120. {refrain-0.2.5 → refrain-0.2.7}/tests/test_discord_rpc.py +0 -0
  121. {refrain-0.2.5 → refrain-0.2.7}/tests/test_imports.py +0 -0
  122. {refrain-0.2.5 → refrain-0.2.7}/tests/test_mpris_dispatch.py +0 -0
  123. {refrain-0.2.5 → refrain-0.2.7}/tests/test_paths.py +0 -0
  124. {refrain-0.2.5 → refrain-0.2.7}/tests/test_sources_base.py +0 -0
  125. {refrain-0.2.5 → refrain-0.2.7}/tests/test_timing.py +0 -0
  126. {refrain-0.2.5 → refrain-0.2.7}/tests/test_tray_init.py +0 -0
  127. {refrain-0.2.5 → refrain-0.2.7}/tests/test_update_dialog_body.py +0 -0
  128. {refrain-0.2.5 → refrain-0.2.7}/tests/test_updater.py +0 -0
@@ -104,10 +104,38 @@ jobs:
104
104
  mv *.AppImage.zsync ../../dist/ 2>/dev/null || true
105
105
  ls -la ../../dist/
106
106
 
107
+ # Extract the matching `## [X.Y.Z]` section from CHANGELOG.md
108
+ # and feed it into the GitHub release body. Without this we'd
109
+ # fall back to GitHub's auto-generated "Full Changelog: …"
110
+ # one-liner — which is also what the in-app
111
+ # Settings → Updates → "Latest release notes" pane renders, so
112
+ # users would only ever see the bare compare-link there.
113
+ - name: Extract CHANGELOG section for the release body
114
+ run: |
115
+ TAG="${GITHUB_REF#refs/tags/v}"
116
+ # awk: print everything between `## [TAG]` and the next
117
+ # top-level ## section, excluding the heading lines themselves.
118
+ awk -v ver="${TAG}" '
119
+ $0 ~ "^## \\[" ver "\\]" { p=1; next }
120
+ p && /^## \[/ { exit }
121
+ p { print }
122
+ ' CHANGELOG.md > release-notes.md
123
+ echo "::group::release-notes.md"
124
+ cat release-notes.md
125
+ echo "::endgroup::"
126
+ if [ ! -s release-notes.md ]; then
127
+ echo "::error::CHANGELOG section for v${TAG} extracted to nothing"
128
+ exit 1
129
+ fi
130
+
107
131
  - name: Create GitHub release
108
132
  uses: softprops/action-gh-release@v3
109
133
  with:
110
- generate_release_notes: true
134
+ # Use our CHANGELOG section (extracted above), not GitHub's
135
+ # auto-generated notes — the in-app update dialog renders
136
+ # this body verbatim so it has to actually be the changelog.
137
+ body_path: release-notes.md
138
+ generate_release_notes: false
111
139
  draft: false
112
140
  prerelease: ${{ contains(github.ref, '-') }} # e.g. v0.2.0-rc1
113
141
  name: "Refrain ${{ github.ref_name }}"
@@ -7,6 +7,226 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.7] - 2026-05-07
11
+
12
+ Tray-menu polish + Discord-RPC reliability fixes. Every tray action
13
+ now carries an icon, song-info rows render in normal menu text
14
+ colour instead of greyed-out indented rows, middle-click on the
15
+ tray toggles play/pause, and Discord handshake errors no longer
16
+ spam the log with unexpected-exception tracebacks.
17
+
18
+ ### Added
19
+
20
+ - **Middle-click on the tray icon toggles play/pause.** Clicks the
21
+ PlayPause command on whichever MPRIS source Refrain is currently
22
+ attached to — same path as the tray-menu Play/Pause item, but
23
+ reachable without opening the menu. Plain left-click still opens
24
+ Settings; right-click still shows the menu.
25
+ - **Tray menu actions now all carry icons.** Once any QMenu item
26
+ has an icon, the menu reserves the icon column for *all* items —
27
+ so adding the red ✕ to Quit Refrain made every previously
28
+ text-only action read as misaligned with blank icon space. Action
29
+ items now use freedesktop theme icons (`configure`,
30
+ `view-list-text`, `view-refresh`, `media-skip-backward`,
31
+ `media-playback-start`, `media-playback-pause`,
32
+ `media-skip-forward`) which match the user's Plasma / GNOME /
33
+ Breeze icon set. Update + Quit keep their bundled coloured-accent
34
+ SVGs. Source strings for the playback / restart actions dropped
35
+ their unicode-glyph prefixes (`⏮ ⏵ ⏸ ⏭ ⟳`) so the new icons
36
+ don't read as duplicates; all 9 .ts files updated to match. The
37
+ Quit ✕ glyph was redrawn to fill its 16×16 viewBox edge-to-edge
38
+ so it visually matches the theme icons' pixel weight.
39
+ - **Song-info rows in the tray menu now render with proper icons
40
+ and full-strength text colour.** Title / Artist / Progress /
41
+ Discord-status used to be `setEnabled(False)` so KDE's DBusMenu
42
+ rendered them muted-grey + indented + iconless next to the white
43
+ action labels below. They're now enabled (a stray click opens
44
+ Settings — their natural "tell me more" target) and carry
45
+ `view-media-track`, `view-media-artist`, `chronometer`, and
46
+ `network-connect` / `network-disconnect` icons. The `♪` prefix
47
+ on the title and the `●/○` prefix on the Discord-status row are
48
+ gone — the icons replace them.
49
+
50
+ ### Fixed
51
+
52
+ - **Settings → Updates "Latest release notes" QTextBrowser was
53
+ parent-less.** `_build_updates_tab` called
54
+ `notes_group.setLayout(QVBoxLayout())` on a QGroupBox that
55
+ already had a QFormLayout installed by `_new_group`. Qt refuses
56
+ to install a second layout (logs "QLayout: Attempting to add
57
+ QLayout to QGroupBox which already has a layout") so the new
58
+ VBoxLayout was discarded and the QTextBrowser ended up as a
59
+ free-floating top-level widget kept alive only by the Python
60
+ reference. In production it still painted because the parent
61
+ page laid it out somewhere reasonable, but it was outside the
62
+ widget tree (`findChildren(QTextBrowser)` returned 0). The view
63
+ is now added via the existing form layout's `addRow`.
64
+ - **Discord handshake errors logged as ERROR-level unexpected
65
+ exceptions with a full traceback every retry.** When Discord
66
+ responded to a `pypresence.Presence.connect()` with an error
67
+ message (e.g. "User logged out" — DiscordError code 1000, or
68
+ "Invalid Client ID"), the bare `except Exception:` clause in
69
+ `_ensure_connected` swallowed it as a programmer bug and emitted
70
+ a noisy traceback every backoff cycle. `pypresence.exceptions.
71
+ DiscordError` is now caught explicitly and logged at INFO; the
72
+ back-off jumps straight to the 15 s ceiling so Refrain doesn't
73
+ retry tightly while the user is signing back into Discord or
74
+ fixing a typo'd client_id.
75
+ - **First Discord push after a forced reconnect could be deduped
76
+ out of existence.** The presence-update dedupe cache lived on
77
+ the orchestrator across `Presence` instance replacements; after
78
+ a reconnect with byte-identical payload data, the new pipe
79
+ carried no state on Discord's side but our cache still claimed
80
+ "we already pushed this", so the user saw "no activity" until
81
+ metadata changed. `_last_payload` is now reset to None in the
82
+ successful-connect path of `_ensure_connected`.
83
+ - **Empty artist row in tray menu when nothing was playing.** The
84
+ artist QAction stayed visible with empty text whenever no track
85
+ was active, rendering as a tall blank line right under
86
+ "(nothing playing)". Now hidden until a real track populates it.
87
+ - **Settings → Updates "Latest release notes" pane stayed empty
88
+ on every Refrain restart within the 24-hour update-check
89
+ cooldown.** `maybe_check_on_startup` short-circuited *before*
90
+ fetching anything when the cooldown was active, so
91
+ `releaseInfoFetched` never fired and `set_latest_release` never
92
+ populated the inline pane. The v0.2.6 workaround — re-fetching
93
+ on Updates-tab activation with `manual=True` — additionally
94
+ fired the "up to date" / "update available" popups on every tab
95
+ visit, which was its own bug. Startup now always fetches the
96
+ release info (silent, no popups) so the pane is populated
97
+ immediately; the cooldown only gates whether that fetch *also*
98
+ triggers the auto-popup. The on-tab-activation fetch is gone.
99
+
100
+ ## [0.2.6] - 2026-05-07
101
+
102
+ Localization expansion plus a bundle of reliability fixes accumulated
103
+ since v0.2.5 across the config layer, restart path, cover cache, IPC
104
+ sandbox bridge, and update flow.
105
+
106
+ ### Added
107
+
108
+ - **8 new UI languages.** Refrain ships full translations (127 / 127
109
+ strings each) for Spanish (`es`), French (`fr`), Portuguese (`pt`),
110
+ Italian (`it`), Russian (`ru`), Polish (`pl`), Japanese (`ja`), and
111
+ Simplified Chinese (`zh_CN`) on top of the existing English source
112
+ + German. The Settings → Advanced → Language dropdown lists each
113
+ language under its native endonym ("Español", "Français",
114
+ "Português", "Italiano", "Русский", "Polski", "日本語",
115
+ "简体中文"). Picking a language restarts Refrain so the
116
+ QTranslator picks up the new `.qm`. Together with English and
117
+ German, this covers roughly 3 billion native speakers.
118
+
119
+ ### Fixed
120
+
121
+ - **GitHub release bodies were one-line "Full Changelog: …"** — the
122
+ release workflow used `generate_release_notes: true`, which is
123
+ GitHub's auto-generated content (just a compare-link to the
124
+ previous tag). The in-app inline release-notes pane and the
125
+ popup dialog rendered that 79-character body verbatim. The
126
+ workflow now extracts the matching `## [X.Y.Z]` section from
127
+ CHANGELOG.md via awk and feeds it as the release `body_path`.
128
+ Existing v0.1.0 – v0.2.5 release bodies were backfilled out-of-
129
+ band via `gh release edit --notes-file …` against the same
130
+ extraction.
131
+ - **Release-notes pane stayed empty after every Refrain restart** —
132
+ the orchestrator's 24-hour startup cooldown blocks
133
+ `maybe_check_on_startup` whenever `last_check_ts` is recent, and
134
+ a fresh process has no cached `_latest`. `SettingsWindow` now
135
+ emits `updatesTabRequested` when the user activates the Updates
136
+ tab; app.py wires that to a slot that triggers
137
+ `updater.check_now(manual=True)` only when no cached release is
138
+ available this session. First visit fetches in the background;
139
+ subsequent visits use the cache.
140
+ - **`os.execvp` failure during Restart had no fallback** — if
141
+ `sys.argv[0]` wasn't a runnable script (typical of
142
+ `python -m refrain` source-checkout invocation, where argv[0] is
143
+ `__main__.py`), execvp raised an unhandled OSError. Detect
144
+ unrunnable paths up front and fall back to
145
+ `<sys.executable> -m refrain`, which always works because
146
+ Refrain is currently importable. The fallback is also retried
147
+ from the actual OSError handler if the first execvp fails for
148
+ any other reason.
149
+ - **`Config.save` leaked .tmp files on write failure** — a disk-
150
+ full / read-only / permission-denied error during the atomic
151
+ write left a stale `*.tmp` sibling next to the real config
152
+ forever. Now wrapped in a `try/finally` that unlinks the partial
153
+ file before re-raising.
154
+ - **`Config._format_value` didn't escape newline / tab / CR** —
155
+ any string value with those characters (extremely rare in our
156
+ schema, but possible via a hand-edited config or a future
157
+ field) would produce broken TOML that tomllib rejects on next
158
+ load, tripping the "config unreadable, using defaults" fallback
159
+ and silently losing every setting. Added `\n` / `\r` / `\t`
160
+ escapes alongside the existing `\\` / `\"`.
161
+ - **CHANGELOG link `[0.2.1]` was a 404** — pointed at a non-
162
+ existent `v0.2.1` tag (the v0.2.1 commit was made but never
163
+ tagged or published; its content rolled into v0.2.2). Replaced
164
+ with a commit-hash link to `6b339ba`. ROADMAP gained a matching
165
+ `(rolled into v0.2.2; never separately tagged)` suffix.
166
+ - **Settings → Apply with a non-writable config directory crashed
167
+ silently.** `Config.save` raised OSError (read-only home, disk
168
+ full, permissions). PySide6's signal-slot machinery logged
169
+ "uncaught exception in slot" and Apply visibly did nothing.
170
+ Now wrapped in try/except: the user gets a `QMessageBox.critical`
171
+ naming the path + the OS error, and the in-memory daemon state
172
+ still updates so the rest of the session works.
173
+ - **`autostart.enable` / `disable` raised on filesystem failure**
174
+ with no user feedback. Now they return a bool and log a warning
175
+ on failure; Settings → Apply continues instead of crashing.
176
+ - **Update dialog `_on_update_clicked` could TypeError on a
177
+ re-entered click** — `close_btn.clicked.disconnect()` raises if
178
+ the signal has no connections (unusual but possible during
179
+ fast double-clicks while runner setup is in flight). Wrapped
180
+ in `contextlib.suppress(TypeError)` like the matching
181
+ `_on_runner_finished` path.
182
+ - **Refrain crashed at startup when no D-Bus session bus was
183
+ available** — `single_instance.acquire()` propagated
184
+ `dbus.DBusException` past `app.py`'s `except AlreadyRunning`,
185
+ giving the user an unhandled exception traceback instead of a
186
+ message box. New `SessionBusUnavailable` exception type
187
+ surfaces the actual reason ("D-Bus session bus unavailable")
188
+ with hints about `dbus-daemon` / `DBUS_SESSION_BUS_ADDRESS`.
189
+ - **`setup_logging(level)` and `_apply_log_level` crashed on
190
+ non-string `log_level`** — a hand-edited config with
191
+ `log_level = false` (boolean) or `log_level = 5` (int) would
192
+ AttributeError on `.upper()`. Now coerced to str defensively.
193
+ - **`Config._construct` accepted wrongly-typed values silently** —
194
+ `cover_cache_size = "200"` (string) would survive the dataclass
195
+ __init__ but then crash in `_prune_cover_cache` with TypeError on
196
+ `len(files) - "200"`. The new `_coerce_value()` helper coerces
197
+ primitives where it makes sense (int from numeric string,
198
+ bool from "true"/"false") and drops + warns when it doesn't
199
+ (e.g. `log_level = false` → use the field default). 1 new test
200
+ in test_config.py.
201
+ - **Cover-art cache writes leaked .tmp files on disk failure** —
202
+ `cover_art._write_cache` and `download_cover_image` called
203
+ `tmp.write_bytes()` / `os.replace()` without exception
204
+ handling. A read-only `~/.cache/refrain/covers/` left a stale
205
+ `*.tmp` next to the real file forever. Both now wrap in
206
+ try/except, clean up the partial tmp, and log debug.
207
+ - **`_bridge_sandboxed_ipc_socket` could raise on locked-down
208
+ sandbox dirs** — a permission-denied `iterdir()` of one of the
209
+ Snap/Flatpak candidate paths used to propagate up through
210
+ `DiscordRPC._ensure_connected` → daemon `_tick`'s except, killing
211
+ the connect attempt every poll. Now wrapped: failures log debug
212
+ and the connect proceeds without sandbox-bridging.
213
+ - **iTunes search shape-check.** A malformed response (string
214
+ error, list-not-dict, missing fields) used to AttributeError
215
+ on `data.get("results", [])` or `first.get(...)`. Now both
216
+ layers check `isinstance(...)` first and treat unexpected
217
+ shapes as "no result" — caller falls back to the bundled
218
+ brand icon.
219
+ - **`_prune_cover_cache` could crash daemon startup** on a
220
+ permission-denied or corrupt cache dir. Now wraps the
221
+ enumerate/sort in try/except, logs debug, returns 0 — the
222
+ daemon starts normally and lives without prune until next
223
+ session. Plus defensive int() coercion of `max_entries`.
224
+
225
+ ### Tests
226
+
227
+ - 3 new in `tests/test_config.py` covering newline-escape
228
+ round-trip and the .tmp cleanup-on-failure path.
229
+
10
230
  ## [0.2.5] - 2026-05-07
11
231
 
12
232
  A same-day patch on top of v0.2.4 driven by user feedback that the
@@ -411,6 +631,11 @@ of reliability + UX work driven by live-testing the v0.2.1 build.
411
631
 
412
632
  ## [0.2.1] - 2026-05-06
413
633
 
634
+ > **Note:** v0.2.1 was never tagged or published as a separate
635
+ > release. The changes documented below were rolled into the
636
+ > subsequent v0.2.2 release. Kept here for changelog continuity —
637
+ > see [v0.2.2](#022---2026-05-06) for the actually-shipped delta.
638
+
414
639
  Polish release built around the v0.2.0 surface. The Settings window got
415
640
  a UI overhaul, two notification cover-art races are gone, and the
416
641
  external code-review punch list landed in full (atomic config writes,
@@ -844,12 +1069,14 @@ with a proper, installable Linux app.
844
1069
  pip-audit, trufflehog, release), Dependabot, issue + PR templates,
845
1070
  `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`.
846
1071
 
847
- [Unreleased]: https://github.com/Rockykln/refrain/compare/v0.2.5...HEAD
1072
+ [Unreleased]: https://github.com/Rockykln/refrain/compare/v0.2.7...HEAD
1073
+ [0.2.7]: https://github.com/Rockykln/refrain/compare/v0.2.6...v0.2.7
1074
+ [0.2.6]: https://github.com/Rockykln/refrain/compare/v0.2.5...v0.2.6
848
1075
  [0.2.5]: https://github.com/Rockykln/refrain/compare/v0.2.4...v0.2.5
849
1076
  [0.2.4]: https://github.com/Rockykln/refrain/compare/v0.2.3...v0.2.4
850
1077
  [0.2.3]: https://github.com/Rockykln/refrain/compare/v0.2.2...v0.2.3
851
1078
  [0.2.2]: https://github.com/Rockykln/refrain/compare/v0.2.1...v0.2.2
852
- [0.2.1]: https://github.com/Rockykln/refrain/compare/v0.2.0...v0.2.1
1079
+ [0.2.1]: https://github.com/Rockykln/refrain/commit/6b339ba
853
1080
  [0.2.0]: https://github.com/Rockykln/refrain/compare/v0.1.5...v0.2.0
854
1081
  [0.1.5]: https://github.com/Rockykln/refrain/compare/v0.1.4...v0.1.5
855
1082
  [0.1.4]: https://github.com/Rockykln/refrain/compare/v0.1.3...v0.1.4
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refrain
3
- Version: 0.2.5
3
+ Version: 0.2.7
4
4
  Summary: Discord Rich Presence for Apple Music on Linux
5
5
  Project-URL: Homepage, https://github.com/Rockykln/refrain
6
6
  Project-URL: Repository, https://github.com/Rockykln/refrain
@@ -153,6 +153,15 @@ registers their own (free, takes 30 seconds):
153
153
  4. Launch Refrain → *Settings → General → Discord Client ID* → paste,
154
154
  *Apply*.
155
155
 
156
+ The first time you launch Refrain without a configured ID, the
157
+ welcome wizard pops up with the setup steps + a live diagnostics
158
+ panel that probes your D-Bus session and Discord IPC socket so you
159
+ know up front whether your environment can host the RPC at all.
160
+
161
+ <p align="center">
162
+ <img src="docs/screenshots/welcome.png" alt="Welcome wizard" width="560"/>
163
+ </p>
164
+
156
165
  That's it. The status will appear in Discord on the next track change.
157
166
 
158
167
  ## Configuration
@@ -187,7 +196,7 @@ poll_interval_ms = 500
187
196
  log_level = "INFO"
188
197
  cover_cache_size = 200 # disk cap for cached covers
189
198
  idle_grace_s = 30 # clear status when same track plays past duration + grace; 0 disables
190
- language = "system" # "system" follows QLocale; "en" / "de" / … force a translation
199
+ language = "system" # "system" follows QLocale; "en", "de", "es", "fr", "pt", "it", "ru", "pl", "ja", "zh_CN" force a translation
191
200
  ```
192
201
 
193
202
  Per-source `client_id_*` fields let Apple Music render under one Discord
@@ -119,6 +119,15 @@ registers their own (free, takes 30 seconds):
119
119
  4. Launch Refrain → *Settings → General → Discord Client ID* → paste,
120
120
  *Apply*.
121
121
 
122
+ The first time you launch Refrain without a configured ID, the
123
+ welcome wizard pops up with the setup steps + a live diagnostics
124
+ panel that probes your D-Bus session and Discord IPC socket so you
125
+ know up front whether your environment can host the RPC at all.
126
+
127
+ <p align="center">
128
+ <img src="docs/screenshots/welcome.png" alt="Welcome wizard" width="560"/>
129
+ </p>
130
+
122
131
  That's it. The status will appear in Discord on the next track change.
123
132
 
124
133
  ## Configuration
@@ -153,7 +162,7 @@ poll_interval_ms = 500
153
162
  log_level = "INFO"
154
163
  cover_cache_size = 200 # disk cap for cached covers
155
164
  idle_grace_s = 30 # clear status when same track plays past duration + grace; 0 disables
156
- language = "system" # "system" follows QLocale; "en" / "de" / … force a translation
165
+ language = "system" # "system" follows QLocale; "en", "de", "es", "fr", "pt", "it", "ru", "pl", "ja", "zh_CN" force a translation
157
166
  ```
158
167
 
159
168
  Per-source `client_id_*` fields let Apple Music render under one Discord
@@ -145,7 +145,7 @@ What's done, what's next, what's deliberately not in scope.
145
145
  - **Window-title cleanup** — no more "Refrain — Settings — Refrain"
146
146
  triple; Qt's auto-suffix is the only place "Refrain" appears.
147
147
 
148
- ## Done — v0.2.1
148
+ ## Done — v0.2.1 (rolled into v0.2.2; never separately tagged)
149
149
 
150
150
  - **Settings UI overhaul** — consistent `QGroupBox` + form-layout
151
151
  across every tab, left-aligned group titles via stylesheet
@@ -285,14 +285,97 @@ What's done, what's next, what's deliberately not in scope.
285
285
 
286
286
  ## Up next — v0.3
287
287
 
288
- - **Polishing the wrapped i18n surface** — wrap the remaining
289
- fixed-position strings (date formatters, advanced-tab subtitles,
290
- bluetooth picker labels) so the German build covers everything a
291
- user sees, not just the high-traffic widgets.
292
288
  - **Stable-release AUR build** that doesn't rely on the GitHub
293
289
  release tarball — switch to a `git`-source PKGBUILD pinned to the
294
290
  signed tag, so AUR users get the exact same commit the release
295
291
  workflow ships.
292
+ - **Wrap the remaining fixed-position strings** (date formatters,
293
+ bluetooth picker labels) so every language reaches 100% coverage,
294
+ not just the high-traffic widgets.
295
+
296
+ ## Done — v0.2.7
297
+
298
+ - **Tray-menu polish.** Every action carries an icon now —
299
+ freedesktop theme icons (`configure`, `view-list-text`,
300
+ `view-refresh`, `media-skip-{backward,forward}`,
301
+ `media-playback-{start,pause}`) for built-in actions plus the
302
+ bundled coloured-accent SVGs for Update / Quit. Source strings
303
+ for the playback / restart / Discord-status entries dropped
304
+ their unicode-glyph prefixes (`⏮ ⏵ ⏸ ⏭ ⟳ ●/○`); all 9 .ts
305
+ files updated. Quit ✕ glyph redrawn to fill its 16×16 viewBox.
306
+ - **Song-info rows render with proper colour + icons.** Title /
307
+ Artist / Progress / Discord-status used to be `setEnabled(False)`
308
+ so KDE / GNOME's DBusMenu painted them muted-grey + indented +
309
+ iconless. They're now enabled (a click opens Settings) and
310
+ carry `view-media-{track,artist}`, `chronometer`, and
311
+ `network-{connect,disconnect}` icons.
312
+ - **Middle-click on the tray icon toggles play/pause.** Same
313
+ PlayPause command as the tray-menu Play/Pause item, but
314
+ reachable without opening the menu.
315
+ - **Discord handshake errors no longer crash-log.** `pypresence.
316
+ exceptions.DiscordError` (raised when Discord rejects the
317
+ handshake — "User logged out", "Invalid Client ID") fell
318
+ through the bare `except Exception:` and emitted a full
319
+ traceback every retry cycle. Now caught explicitly, logged at
320
+ INFO, with backoff jumping straight to the 15 s ceiling so
321
+ Refrain doesn't retry tightly while you sign back into Discord.
322
+ - **First Discord push after a forced reconnect can no longer be
323
+ deduped out.** `_last_payload` is reset in the connect-success
324
+ path so the dedup cache from a previous Presence instance
325
+ doesn't suppress the first push on the new pipe.
326
+ - **Settings → Updates "Latest release notes" pane populates on
327
+ every restart.** v0.2.6's startup auto-check short-circuited
328
+ before fetching when the 24h cooldown was active; the v0.2.6
329
+ workaround re-fetched on every Updates-tab visit. Both gone —
330
+ startup runs a silent fetch (no popups, no cooldown bump) so
331
+ the pane is populated immediately, while the cooldown still
332
+ gates the auto-nag popup.
333
+ - **Empty artist row in tray menu when nothing was playing**
334
+ (`setVisible(False)` until a real track populates it).
335
+
336
+ ## Done — v0.2.6
337
+
338
+ - **8 new UI languages** at 127/127 strings each: Spanish (`es`),
339
+ French (`fr`), Portuguese (`pt`), Italian (`it`), Russian
340
+ (`ru`), Polish (`pl`), Japanese (`ja`), Simplified Chinese
341
+ (`zh_CN`). Settings → Advanced → Language dropdown lists each
342
+ under its native endonym. Together with English + German this
343
+ covers ~3 billion native speakers.
344
+ - **GitHub release bodies were one-line "Full Changelog: …"**
345
+ because the workflow used `generate_release_notes: true`. Now
346
+ extracts the matching `## [X.Y.Z]` section from CHANGELOG.md
347
+ via awk and feeds it as `body_path`; existing v0.1.0 – v0.2.5
348
+ bodies were backfilled out-of-band.
349
+ - **Reliability sweep**: `os.execvp` Restart fallback to
350
+ `python -m refrain` when `argv[0]` isn't runnable;
351
+ Config.save .tmp cleanup on disk-full / read-only;
352
+ `Config._format_value` escapes `\n / \r / \t`;
353
+ `Config._construct` coerces wrongly-typed primitives (e.g.
354
+ `cover_cache_size = "200"`) instead of crashing in
355
+ `_prune_cover_cache`; cover-art `_write_cache` +
356
+ `download_cover_image` clean up `.tmp` siblings on disk
357
+ failure; `_bridge_sandboxed_ipc_socket` permission-error wrap;
358
+ iTunes Search shape-checks; `SessionBusUnavailable` exception
359
+ surfacing instead of bare DBusException; Apply error toast for
360
+ non-writable config dirs; `setup_logging`/`_apply_log_level`
361
+ coerce non-string `log_level`.
362
+
363
+ ## Done — v0.2.5
364
+
365
+ - **AUR + Flatpak self-update spawns a terminal automatically**
366
+ running the package-manager command (`yay -Syu refrain` /
367
+ `flatpak update io.github.Rockykln.Refrain`) instead of just
368
+ showing the command in a popup. Falls back to the previous
369
+ message-box hint when no terminal is on PATH (probe list:
370
+ konsole, gnome-terminal, xfce4-terminal, kitty, alacritty,
371
+ foot, xterm, wezterm).
372
+ - **pip auto-update detects "no-op" exits.** PyPI's CDN can lag
373
+ the GitHub Releases API by minutes, so a "0.2.X available"
374
+ check could succeed while pip exited 0 with "Requirement
375
+ already satisfied" (no upgrade). Refrain now parses pip's
376
+ stdout to distinguish "Successfully installed" from
377
+ "Requirement already satisfied" and tells the user to retry
378
+ shortly instead of restarting into the same version.
296
379
 
297
380
  ## Done — v0.2.2
298
381
 
@@ -4,14 +4,16 @@ The README references screenshots from this directory. To regenerate them:
4
4
 
5
5
  | File | What to capture |
6
6
  |----------------------------|------------------------------------------------------|
7
- | `tray-menu.png` | Right-click the Refrain tray icon while a track plays. The full menu, with track name, artist, time and player controls visible. |
7
+ | `tray-menu.png` | Right-click the Refrain tray icon while a track plays. Captures title / artist / progress / Discord-status info rows + Previous / Play-Pause / Next / Settings / Live log / Restart / Quit, all with theme icons. |
8
8
  | `settings-general.png` | Settings window → *General* tab, showing Discord client ID, Privacy combo, autostart, notifications, cover art, buttons. |
9
9
  | `settings-sources.png` | Settings window → *Sources* tab, showing MPRIS toggle, Bluetooth toggle, and the paired-device dropdown populated. |
10
- | `settings-updates.png` | Settings window → *Updates* tab. Auto-check on, last-checked timestamp visible, hint text wrapped. |
10
+ | `settings-updates.png` | Settings window → *Updates* tab. Auto-check on, last-checked timestamp visible, the "Latest release notes" pane populated from CHANGELOG. |
11
+ | `settings-advanced.png` | Settings window → *Advanced* tab, showing the Language dropdown (11 entries: System default + 10 native endonyms), poll interval, log level, cover cache size, idle grace, and the Reset-all-settings button. |
11
12
  | `update-dialog.png` | The update-available dialog (only appears when a new version is found). Trigger it locally by editing `__version__` to something older and clicking *Check for updates now*. |
12
13
  | `discord-rpc.png` | Cropped section of your Discord profile showing the "Listening to" card with cover art + elapsed/total timer. |
13
14
  | `notification.png` | A desktop notification fired by Refrain on track change. KDE Plasma's notification with the album cover at left. |
14
15
  | `live-log.png` | The live-log window, opened via tray → *Live log…*, with at least 10 lines of streamed log. |
16
+ | `welcome.png` | First-run welcome wizard — shown when no `client_id` is configured yet. Diagnostics panel + Discord-app guidance. |
15
17
 
16
18
  ## How to capture on KDE Plasma
17
19
 
@@ -30,7 +30,7 @@ AppDir:
30
30
  id: io.github.Rockykln.Refrain
31
31
  name: Refrain
32
32
  icon: refrain
33
- version: 0.2.5
33
+ version: 0.2.7
34
34
  exec: usr/bin/python3
35
35
  exec_args: -m refrain $@
36
36
 
@@ -1,6 +1,6 @@
1
1
  pkgbase = refrain
2
2
  pkgdesc = Discord Rich Presence for Apple Music on Linux
3
- pkgver = 0.2.4
3
+ pkgver = 0.2.6
4
4
  pkgrel = 1
5
5
  url = https://github.com/Rockykln/refrain
6
6
  arch = any
@@ -14,7 +14,7 @@ pkgbase = refrain
14
14
  depends = python-dbus
15
15
  depends = pyside6
16
16
  optdepends = python-gobject: enables MPRIS-server publication so Plasma media controls reach Refrain
17
- source = refrain-0.2.4.tar.gz::https://github.com/Rockykln/refrain/archive/v0.2.4.tar.gz
18
- sha256sums = cb4e712a2809bd237c4ccaebd2cc81afa28286b4d260072f74bf94ce530a0ea3
17
+ source = refrain-0.2.6.tar.gz::https://github.com/Rockykln/refrain/archive/v0.2.6.tar.gz
18
+ sha256sums = d5d99a19be2b7414e7d7d1324c7893d2a227a60d15c14969d69102f9d2850294
19
19
 
20
20
  pkgname = refrain
@@ -1,7 +1,7 @@
1
1
  # Maintainer: Rockykln <contact@rockykln.com>
2
2
 
3
3
  pkgname=refrain
4
- pkgver=0.2.5
4
+ pkgver=0.2.7
5
5
  pkgrel=1
6
6
  pkgdesc="Discord Rich Presence for Apple Music on Linux"
7
7
  arch=('any')
@@ -23,7 +23,7 @@ makedepends=(
23
23
  'python-wheel'
24
24
  )
25
25
  source=("$pkgname-$pkgver.tar.gz::https://github.com/Rockykln/refrain/archive/v$pkgver.tar.gz")
26
- sha256sums=('SKIP')
26
+ sha256sums=('d5d99a19be2b7414e7d7d1324c7893d2a227a60d15c14969d69102f9d2850294')
27
27
 
28
28
  build() {
29
29
  cd "$pkgname-$pkgver"
@@ -51,6 +51,38 @@
51
51
  <content_rating type="oars-1.1" />
52
52
 
53
53
  <releases>
54
+ <release version="0.2.7" date="2026-05-07">
55
+ <description>
56
+ <p>
57
+ Tray menu polish + Discord-RPC reliability. Every tray
58
+ action now carries an icon (theme-matched on Plasma /
59
+ GNOME / Breeze), the song-info rows render in normal text
60
+ colour with proper icons instead of greyed-out indented
61
+ rows, and middle-clicking the tray icon toggles
62
+ play/pause. Discord handshake errors (e.g. "User logged
63
+ out", "Invalid Client ID") are now caught explicitly
64
+ instead of crash-logged as unexpected exceptions, with a
65
+ longer back-off so Refrain doesn't retry tightly while
66
+ you're signing back in. The Settings → Updates
67
+ "Latest release notes" pane populates on every restart
68
+ regardless of the 24-hour update-check cooldown.
69
+ </p>
70
+ </description>
71
+ </release>
72
+ <release version="0.2.6" date="2026-05-07">
73
+ <description>
74
+ <p>
75
+ Localization expansion. Refrain now ships full UI
76
+ translations for 8 additional languages — Spanish,
77
+ French, Portuguese, Italian, Russian, Polish, Japanese,
78
+ and Simplified Chinese — covering an additional ~3
79
+ billion native speakers worldwide on top of English and
80
+ German. Also fixes the empty "Latest release notes" pane
81
+ that pipx-installed v0.2.5 showed on the Updates tab
82
+ when no auto-check had fetched the body yet.
83
+ </p>
84
+ </description>
85
+ </release>
54
86
  <release version="0.2.5" date="2026-05-07">
55
87
  <description>
56
88
  <p>
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "refrain"
7
- version = "0.2.5"
7
+ version = "0.2.7"
8
8
  description = "Discord Rich Presence for Apple Music on Linux"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1 @@
1
+ __version__ = "0.2.7"