refrain 0.2.7__tar.gz → 0.3.0__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 (136) hide show
  1. {refrain-0.2.7 → refrain-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +1 -1
  2. {refrain-0.2.7 → refrain-0.3.0}/.github/workflows/security.yml +1 -1
  3. {refrain-0.2.7 → refrain-0.3.0}/CHANGELOG.md +89 -1
  4. {refrain-0.2.7 → refrain-0.3.0}/PKG-INFO +66 -13
  5. refrain-0.3.0/PRIVACY.md +148 -0
  6. {refrain-0.2.7 → refrain-0.3.0}/README.md +64 -11
  7. {refrain-0.2.7 → refrain-0.3.0}/ROADMAP.md +179 -97
  8. {refrain-0.2.7 → refrain-0.3.0}/SECURITY.md +16 -3
  9. {refrain-0.2.7 → refrain-0.3.0}/docs/architecture.md +17 -2
  10. refrain-0.3.0/docs/lastfm.md +103 -0
  11. {refrain-0.2.7 → refrain-0.3.0}/packaging/appimage/AppImageBuilder.yml +1 -1
  12. {refrain-0.2.7 → refrain-0.3.0}/packaging/aur/refrain/.SRCINFO +3 -3
  13. {refrain-0.2.7 → refrain-0.3.0}/packaging/aur/refrain/PKGBUILD +6 -2
  14. {refrain-0.2.7 → refrain-0.3.0}/packaging/flatpak/io.github.Rockykln.Refrain.metainfo.xml +23 -0
  15. {refrain-0.2.7 → refrain-0.3.0}/packaging/flatpak/io.github.Rockykln.Refrain.yaml +5 -0
  16. {refrain-0.2.7 → refrain-0.3.0}/pyproject.toml +2 -2
  17. refrain-0.3.0/src/refrain/__init__.py +1 -0
  18. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/app.py +6 -0
  19. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/config.py +137 -3
  20. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/daemon.py +257 -35
  21. refrain-0.3.0/src/refrain/scrobble.py +537 -0
  22. refrain-0.3.0/src/refrain/scrobble_queue.py +216 -0
  23. refrain-0.3.0/src/refrain/secrets_store.py +356 -0
  24. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/ui/log_window.py +4 -1
  25. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/ui/settings_window.py +363 -33
  26. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/ui/welcome_dialog.py +1 -1
  27. {refrain-0.2.7 → refrain-0.3.0}/tests/test_config.py +10 -0
  28. refrain-0.3.0/tests/test_config_preserve.py +106 -0
  29. refrain-0.3.0/tests/test_notify_argv.py +64 -0
  30. refrain-0.3.0/tests/test_scrobble.py +185 -0
  31. refrain-0.3.0/tests/test_scrobble_queue.py +140 -0
  32. refrain-0.3.0/tests/test_scrobbler.py +203 -0
  33. refrain-0.3.0/tests/test_secrets_store.py +135 -0
  34. refrain-0.3.0/tests/test_source_selection.py +80 -0
  35. refrain-0.2.7/src/refrain/__init__.py +0 -1
  36. {refrain-0.2.7 → refrain-0.3.0}/.editorconfig +0 -0
  37. {refrain-0.2.7 → refrain-0.3.0}/.gitattributes +0 -0
  38. {refrain-0.2.7 → refrain-0.3.0}/.github/FUNDING.yml +0 -0
  39. {refrain-0.2.7 → refrain-0.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  40. {refrain-0.2.7 → refrain-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  41. {refrain-0.2.7 → refrain-0.3.0}/.github/codeql/codeql-config.yml +0 -0
  42. {refrain-0.2.7 → refrain-0.3.0}/.github/dependabot.yml +0 -0
  43. {refrain-0.2.7 → refrain-0.3.0}/.github/pull_request_template.md +0 -0
  44. {refrain-0.2.7 → refrain-0.3.0}/.github/workflows/codeql.yml +0 -0
  45. {refrain-0.2.7 → refrain-0.3.0}/.github/workflows/release.yml +0 -0
  46. {refrain-0.2.7 → refrain-0.3.0}/.github/workflows/tests.yml +0 -0
  47. {refrain-0.2.7 → refrain-0.3.0}/.gitignore +0 -0
  48. {refrain-0.2.7 → refrain-0.3.0}/CODE_OF_CONDUCT.md +0 -0
  49. {refrain-0.2.7 → refrain-0.3.0}/CONTRIBUTING.md +0 -0
  50. {refrain-0.2.7 → refrain-0.3.0}/LICENSE +0 -0
  51. {refrain-0.2.7 → refrain-0.3.0}/Makefile +0 -0
  52. {refrain-0.2.7 → refrain-0.3.0}/docs/bluetooth.md +0 -0
  53. {refrain-0.2.7 → refrain-0.3.0}/docs/faq.md +0 -0
  54. {refrain-0.2.7 → refrain-0.3.0}/docs/screenshots/README.md +0 -0
  55. {refrain-0.2.7 → refrain-0.3.0}/docs/screenshots/discord-rpc.png +0 -0
  56. {refrain-0.2.7 → refrain-0.3.0}/docs/screenshots/live-log.png +0 -0
  57. {refrain-0.2.7 → refrain-0.3.0}/docs/screenshots/notification.png +0 -0
  58. {refrain-0.2.7 → refrain-0.3.0}/docs/screenshots/settings-advanced.png +0 -0
  59. {refrain-0.2.7 → refrain-0.3.0}/docs/screenshots/settings-general.png +0 -0
  60. {refrain-0.2.7 → refrain-0.3.0}/docs/screenshots/settings-sources.png +0 -0
  61. {refrain-0.2.7 → refrain-0.3.0}/docs/screenshots/settings-updates.png +0 -0
  62. {refrain-0.2.7 → refrain-0.3.0}/docs/screenshots/tray-menu.png +0 -0
  63. {refrain-0.2.7 → refrain-0.3.0}/docs/screenshots/update-dialog.png +0 -0
  64. {refrain-0.2.7 → refrain-0.3.0}/docs/screenshots/welcome.png +0 -0
  65. {refrain-0.2.7 → refrain-0.3.0}/docs/test-matrix.md +0 -0
  66. {refrain-0.2.7 → refrain-0.3.0}/packaging/README.md +0 -0
  67. {refrain-0.2.7 → refrain-0.3.0}/packaging/aur/refrain-git/PKGBUILD +0 -0
  68. {refrain-0.2.7 → refrain-0.3.0}/packaging/flatpak/io.github.Rockykln.Refrain.desktop +0 -0
  69. {refrain-0.2.7 → refrain-0.3.0}/packaging/flatpak/python-deps.json +0 -0
  70. {refrain-0.2.7 → refrain-0.3.0}/requirements-dev.txt +0 -0
  71. {refrain-0.2.7 → refrain-0.3.0}/requirements.txt +0 -0
  72. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/__main__.py +0 -0
  73. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/assets/icons/github-mark.svg +0 -0
  74. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/assets/icons/menu-quit.svg +0 -0
  75. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/assets/icons/menu-update.svg +0 -0
  76. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/assets/icons/refrain.png +0 -0
  77. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/assets/icons/refrain.svg +0 -0
  78. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/assets/icons/tray-paused-dark.svg +0 -0
  79. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/assets/icons/tray-paused.svg +0 -0
  80. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/assets/icons/tray-playing-dark.svg +0 -0
  81. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/assets/icons/tray-playing.svg +0 -0
  82. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/assets/icons/tray-stopped-dark.svg +0 -0
  83. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/assets/icons/tray-stopped.svg +0 -0
  84. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/assets/refrain.desktop +0 -0
  85. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/autostart.py +0 -0
  86. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/cover_art.py +0 -0
  87. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/cover_fetcher.py +0 -0
  88. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/discord_rpc.py +0 -0
  89. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/i18n/refrain_de.qm +0 -0
  90. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/i18n/refrain_de.ts +0 -0
  91. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/i18n/refrain_es.qm +0 -0
  92. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/i18n/refrain_es.ts +0 -0
  93. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/i18n/refrain_fr.qm +0 -0
  94. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/i18n/refrain_fr.ts +0 -0
  95. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/i18n/refrain_it.qm +0 -0
  96. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/i18n/refrain_it.ts +0 -0
  97. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/i18n/refrain_ja.qm +0 -0
  98. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/i18n/refrain_ja.ts +0 -0
  99. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/i18n/refrain_pl.qm +0 -0
  100. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/i18n/refrain_pl.ts +0 -0
  101. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/i18n/refrain_pt.qm +0 -0
  102. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/i18n/refrain_pt.ts +0 -0
  103. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/i18n/refrain_ru.qm +0 -0
  104. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/i18n/refrain_ru.ts +0 -0
  105. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/i18n/refrain_zh_CN.qm +0 -0
  106. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/i18n/refrain_zh_CN.ts +0 -0
  107. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/logging_setup.py +0 -0
  108. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/paths.py +0 -0
  109. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/single_instance.py +0 -0
  110. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/sources/__init__.py +0 -0
  111. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/sources/base.py +0 -0
  112. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/sources/bluetooth.py +0 -0
  113. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/sources/mpris.py +0 -0
  114. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/sources/mpris_server.py +0 -0
  115. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/timing.py +0 -0
  116. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/ui/__init__.py +0 -0
  117. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/ui/tray.py +0 -0
  118. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/ui/update_dialog.py +0 -0
  119. {refrain-0.2.7 → refrain-0.3.0}/src/refrain/updater.py +0 -0
  120. {refrain-0.2.7 → refrain-0.3.0}/tests/__init__.py +0 -0
  121. {refrain-0.2.7 → refrain-0.3.0}/tests/conftest.py +0 -0
  122. {refrain-0.2.7 → refrain-0.3.0}/tests/test_autostart.py +0 -0
  123. {refrain-0.2.7 → refrain-0.3.0}/tests/test_cover_art.py +0 -0
  124. {refrain-0.2.7 → refrain-0.3.0}/tests/test_cover_fetcher.py +0 -0
  125. {refrain-0.2.7 → refrain-0.3.0}/tests/test_daemon_album_format.py +0 -0
  126. {refrain-0.2.7 → refrain-0.3.0}/tests/test_daemon_idle.py +0 -0
  127. {refrain-0.2.7 → refrain-0.3.0}/tests/test_discord_listening.py +0 -0
  128. {refrain-0.2.7 → refrain-0.3.0}/tests/test_discord_rpc.py +0 -0
  129. {refrain-0.2.7 → refrain-0.3.0}/tests/test_imports.py +0 -0
  130. {refrain-0.2.7 → refrain-0.3.0}/tests/test_mpris_dispatch.py +0 -0
  131. {refrain-0.2.7 → refrain-0.3.0}/tests/test_paths.py +0 -0
  132. {refrain-0.2.7 → refrain-0.3.0}/tests/test_sources_base.py +0 -0
  133. {refrain-0.2.7 → refrain-0.3.0}/tests/test_timing.py +0 -0
  134. {refrain-0.2.7 → refrain-0.3.0}/tests/test_tray_init.py +0 -0
  135. {refrain-0.2.7 → refrain-0.3.0}/tests/test_update_dialog_body.py +0 -0
  136. {refrain-0.2.7 → refrain-0.3.0}/tests/test_updater.py +0 -0
@@ -13,7 +13,7 @@ body:
13
13
  attributes:
14
14
  label: Refrain version
15
15
  description: Run `refrain --version` or check the bottom-right of the settings window.
16
- placeholder: "0.2.3"
16
+ placeholder: "0.3.0"
17
17
  validations:
18
18
  required: true
19
19
 
@@ -78,7 +78,7 @@ jobs:
78
78
  fetch-depth: 0
79
79
 
80
80
  - name: Run TruffleHog
81
- uses: trufflesecurity/trufflehog@17456f8c7d042d8c82c9a8ca9e937231f9f42e26 # v3.95.2
81
+ uses: trufflesecurity/trufflehog@37b77001d0174ebec2fcca2bd83ff83a6d45a3ab # v3.95.3
82
82
  with:
83
83
  # --only-verified: a secret only counts as flagged when TruffleHog
84
84
  # successfully validates it against the live service. Drops the
@@ -7,6 +7,93 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-05-19
11
+
12
+ Last.fm scrobbling (opt-in, alongside the Discord status), with
13
+ credentials kept in the OS keyring instead of plaintext, a
14
+ crash-safe offline scrobble queue, the source-priority fix, the
15
+ cover-replace notifications, a comment-preserving config writer, and
16
+ a Settings reshuffle (Last.fm on its own tab, per-source Discord IDs
17
+ behind an opt-in toggle). Folds in the accumulated v0.2.8 fixes
18
+ (never separately tagged). A full `PRIVACY.md` documents every data
19
+ flow.
20
+
21
+ ### Added
22
+
23
+ - **Last.fm scrobbling** — opt-in, *alongside* the Discord Rich
24
+ Presence (never a replacement), off by default. Register your own
25
+ free Last.fm API account (same bring-your-own-credentials model as
26
+ the Discord Application ID), enter the key + shared secret in
27
+ *Settings → General → Last.fm scrobbling*, and **Connect** via the
28
+ in-app browser auth flow. A track is scrobbled at Last.fm's standard
29
+ threshold (half the track or four minutes, whichever first; > 30 s
30
+ only), with pause/seek-aware play-time accounting. Optional
31
+ `track.updateNowPlaying`. No new dependency — the signed API surface
32
+ is hand-rolled on `urllib` + `hashlib`. Walkthrough:
33
+ [`docs/lastfm.md`](docs/lastfm.md).
34
+ - **Crash-safe persistent scrobble queue**
35
+ (`$XDG_STATE_HOME/refrain/scrobble_queue.jsonl`). A qualifying track
36
+ is queued the instant it earns it, so being offline, a Last.fm
37
+ outage, or quitting Refrain mid-song never loses a scrobble — it's
38
+ submitted (≤ 50-item batches) on the next opportunity. SHA-256
39
+ dedup, 1000-entry cap, atomic writes, corrupt-line tolerant. All
40
+ Last.fm network runs on a worker executor so the poll tick never
41
+ blocks; Privacy `Off` silences scrobbling too.
42
+ - **Cover-art replacement notifications.** When iTunes is slow and the
43
+ ~2 s cover-retry window times out, Refrain now fires the
44
+ brand-fallback notification immediately, then re-issues it with
45
+ `notify-send --replace-id` once the cover finishes downloading — the
46
+ album art swaps into the *same* bubble instead of never appearing
47
+ (or a second popup).
48
+
49
+ ### Changed
50
+
51
+ - **Per-source Discord Client IDs are now opt-in.** The General tab
52
+ showed three Client ID fields; most users need one. The two
53
+ per-source overrides (Apple Music / Bluetooth) are hidden behind a
54
+ *"Use a separate Discord application per source (advanced)"* toggle
55
+ (off by default, auto-enabled when an override is already
56
+ configured so existing setups are untouched).
57
+ - **Source priority now prefers the actively-playing source.** A
58
+ stale *paused* Apple Music browser tab no longer masks music
59
+ actively playing over Bluetooth headphones (idle detection is
60
+ PLAYING-only, so the paused tab never cleared either). MPRIS keeps
61
+ the tie-break when neither is playing.
62
+ - **Config saves preserve comments and unknown keys.** `Config.save()`
63
+ now rewrites only the `key = value` lines Refrain owns; hand-added
64
+ comments, ordering, and keys written by a newer Refrain survive the
65
+ daily silent update-check that stamps `last_check_ts`.
66
+ - **More UI strings localised** — the Updates "last checked"
67
+ timestamp now formats in the active locale (`QLocale`) instead of a
68
+ hard-coded ISO string; the Bluetooth picker labels and the
69
+ live-log "ALL" sentinel are now translatable.
70
+
71
+ ### Fixed
72
+
73
+ - `welcome_dialog.py` relied on `urllib.error` transitively via
74
+ `urllib.request`; an `except urllib.error.URLError` could have
75
+ raised `AttributeError` during handler matching. Imported
76
+ explicitly; dropped the unused `urllib.parse` import.
77
+ - ROADMAP section ordering corrected — forward-looking sections no
78
+ longer sit above shipped releases.
79
+ - Settings dialog no longer crushes/overlaps form rows: the Last.fm
80
+ controls moved to their own tab and every tab page is wrapped in a
81
+ scroll area; the default window size fits the tallest tab with no
82
+ visible scrollbar in English and German.
83
+
84
+ ### Security
85
+
86
+ - **Last.fm credentials are never written to `config.toml`.** The
87
+ shared secret and session token are stored in the OS keyring
88
+ (freedesktop Secret Service — KWallet / GNOME Keyring), encrypted
89
+ at rest, hand-rolled on `dbus-python` (no new dependency). When no
90
+ keyring is reachable they fall back to a `0600`, owner-only file
91
+ separate from the config. `config.toml` itself is now written
92
+ `0600`, and a legacy plaintext secret left by an earlier build is
93
+ auto-migrated into the keyring and scrubbed from disk on the next
94
+ save. Secrets are never logged and only ever leave the machine to
95
+ Last.fm over HTTPS (which is scrobbling itself).
96
+
10
97
  ## [0.2.7] - 2026-05-07
11
98
 
12
99
  Tray-menu polish + Discord-RPC reliability fixes. Every tray action
@@ -1069,7 +1156,8 @@ with a proper, installable Linux app.
1069
1156
  pip-audit, trufflehog, release), Dependabot, issue + PR templates,
1070
1157
  `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`.
1071
1158
 
1072
- [Unreleased]: https://github.com/Rockykln/refrain/compare/v0.2.7...HEAD
1159
+ [Unreleased]: https://github.com/Rockykln/refrain/compare/v0.3.0...HEAD
1160
+ [0.3.0]: https://github.com/Rockykln/refrain/compare/v0.2.7...v0.3.0
1073
1161
  [0.2.7]: https://github.com/Rockykln/refrain/compare/v0.2.6...v0.2.7
1074
1162
  [0.2.6]: https://github.com/Rockykln/refrain/compare/v0.2.5...v0.2.6
1075
1163
  [0.2.5]: https://github.com/Rockykln/refrain/compare/v0.2.4...v0.2.5
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refrain
3
- Version: 0.2.7
3
+ Version: 0.3.0
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
@@ -9,7 +9,7 @@ Project-URL: Changelog, https://github.com/Rockykln/refrain/blob/main/CHANGELOG.
9
9
  Author: Rockykln
10
10
  License-File: LICENSE
11
11
  Keywords: apple-music,bluetooth,discord,linux,mpris,rich-presence
12
- Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Development Status :: 4 - Beta
13
13
  Classifier: Environment :: X11 Applications
14
14
  Classifier: Intended Audience :: End Users/Desktop
15
15
  Classifier: License :: Other/Proprietary License
@@ -55,6 +55,8 @@ streaming from your iPhone over Bluetooth.
55
55
  - Reads playback metadata from **MPRIS** (Apple Music in any major Linux
56
56
  browser) and **BlueZ AVRCP** (any AVRCP-capable Bluetooth source).
57
57
  - Forwards track + cover art to Discord via the local IPC socket.
58
+ - Optionally **scrobbles to Last.fm** alongside Discord (opt-in, with a
59
+ crash-safe offline queue).
58
60
  - Lives in your **system tray** with Play/Pause/Next/Previous controls.
59
61
  - Provides a **settings window** (PySide6) for everything users typically want
60
62
  to tweak — privacy mode, sources, autostart, Bluetooth device picker.
@@ -197,6 +199,15 @@ log_level = "INFO"
197
199
  cover_cache_size = 200 # disk cap for cached covers
198
200
  idle_grace_s = 30 # clear status when same track plays past duration + grace; 0 disables
199
201
  language = "system" # "system" follows QLocale; "en", "de", "es", "fr", "pt", "it", "ru", "pl", "ja", "zh_CN" force a translation
202
+
203
+ [lastfm]
204
+ enabled = false # opt-in, alongside (never replacing) the Discord RPC
205
+ api_key = "" # register your own at last.fm/api/account/create
206
+ username = "" # display only
207
+ scrobble_now_playing = true # also send the ephemeral "now playing" indicator
208
+ # NOTE: the Last.fm shared secret and session key are credentials and
209
+ # are deliberately NOT stored here. They live in your OS keyring
210
+ # (KWallet / GNOME Keyring), encrypted at rest — see "Last.fm" below.
200
211
  ```
201
212
 
202
213
  Per-source `client_id_*` fields let Apple Music render under one Discord
@@ -295,14 +306,40 @@ Behavior is install-type-aware:
295
306
  own upgrade command (`flatpak update …` / `yay -Syu refrain`) so the
296
307
  package manager stays in charge.
297
308
 
309
+ ## Last.fm scrobbling
310
+
311
+ Refrain can scrobble to [Last.fm](https://www.last.fm) **alongside** the
312
+ Discord status — a second, independent channel, never a replacement.
313
+ It's **opt-in** and off by default.
314
+
315
+ Register your own free [API account](https://www.last.fm/api/account/create),
316
+ then open the *Settings → Last.fm* tab: tick **Enable**, paste the
317
+ **API key** + **shared secret**, click **Connect…** and approve the
318
+ browser prompt. Scrobbling starts on the next track — no restart.
319
+
320
+ The **shared secret and session token are stored in your OS keyring**
321
+ (KWallet / GNOME Keyring), encrypted at rest — never in `config.toml`
322
+ (which is itself written owner-only, `0600`). On a system with no
323
+ keyring they fall back to a `0600` file. Credentials only ever leave
324
+ the machine to Last.fm over HTTPS, which is what scrobbling *is*.
325
+
326
+ A track is scrobbled once you've played at least half of it, or four
327
+ minutes (Last.fm's rule), and only if it's longer than 30 s. Scrobbles
328
+ are queued to disk the instant they qualify, so being offline, a
329
+ Last.fm outage, or quitting mid-song never loses them — they submit on
330
+ the next opportunity. Privacy mode `Off` silences scrobbling too.
331
+
332
+ Full walkthrough + troubleshooting: [`docs/lastfm.md`](docs/lastfm.md).
333
+
298
334
  ## File locations
299
335
 
300
- | What | Where |
301
- |--------------|---------------------------------------------|
302
- | Config | `$XDG_CONFIG_HOME/refrain/config.toml` |
303
- | Logs | `$XDG_STATE_HOME/refrain/refrain.log` (rotates) |
304
- | Cover cache | `$XDG_CACHE_HOME/refrain/covers/*.txt` |
305
- | Autostart | `$XDG_CONFIG_HOME/autostart/refrain.desktop` (when enabled) |
336
+ | What | Where |
337
+ |---------------|---------------------------------------------|
338
+ | Config | `$XDG_CONFIG_HOME/refrain/config.toml` |
339
+ | Scrobble queue| `$XDG_STATE_HOME/refrain/scrobble_queue.jsonl` |
340
+ | Logs | `$XDG_STATE_HOME/refrain/refrain.log` (rotates) |
341
+ | Cover cache | `$XDG_CACHE_HOME/refrain/covers/*.txt` |
342
+ | Autostart | `$XDG_CONFIG_HOME/autostart/refrain.desktop` (when enabled) |
306
343
 
307
344
  ## Diagnostics — live log
308
345
 
@@ -317,22 +354,38 @@ without tailing it from a terminal.
317
354
 
318
355
  ## Privacy
319
356
 
320
- The cover-art lookup hits Apple's public iTunes Search API over HTTPS with
321
- just the artist + track name. Nothing else leaves your machine — track
322
- metadata goes to Discord's local IPC socket, not over the network.
357
+ Refrain is **local-first**: no Refrain server, no account, **no
358
+ telemetry**, and the author receives nothing. Data leaves your machine
359
+ **only** when *you* enable an optional integration, and then it goes
360
+ directly to that provider:
361
+
362
+ - **Discord** — only if you set a Discord Application ID; track
363
+ metadata goes to the *local* Discord IPC socket (the Discord client
364
+ then broadcasts it under your account).
365
+ - **Apple iTunes Search** (HTTPS) — artist + track name, only while
366
+ cover-art is enabled, to fetch album art. Untick it for zero egress.
367
+ - **GitHub** (HTTPS) — a daily update check sends only the Refrain
368
+ version + your IP. Disable in *Updates*.
369
+ - **Last.fm** (HTTPS) — opt-in scrobbling only; credentials live in
370
+ your **OS keyring** (encrypted at rest), never in `config.toml`.
371
+
372
+ `Privacy → Off` is the global kill switch (no Discord status, no
373
+ scrobbling) while keeping the tray + controls running.
323
374
 
324
- `Off` privacy mode disables the Discord status entirely while keeping the
325
- tray + controls running.
375
+ Full data-flow, retention and erasure details — written to GDPR
376
+ transparency expectations — are in [`PRIVACY.md`](PRIVACY.md).
326
377
 
327
378
  ## Documentation
328
379
 
329
380
  - [Architecture overview](docs/architecture.md) — threads, D-Bus surface, file paths
330
381
  - [FAQ](docs/faq.md)
331
382
  - [Bluetooth quick-start](docs/bluetooth.md) — pair + AVRCP setup walkthrough
383
+ - [Last.fm scrobbling](docs/lastfm.md) — API account + connect walkthrough
332
384
  - [Test matrix](docs/test-matrix.md) — supported distros, smoke-check checklist
333
385
  - [Roadmap](ROADMAP.md)
334
386
  - [Changelog](CHANGELOG.md)
335
387
  - [Contributing](CONTRIBUTING.md) — dev setup, testing, code style
388
+ - [Privacy & data protection](PRIVACY.md) — every data flow, retention, erasure
336
389
  - [Security policy](SECURITY.md)
337
390
  - [Packaging guide](packaging/README.md) — AUR, Flatpak, AppImage build steps
338
391
 
@@ -0,0 +1,148 @@
1
+ # Privacy & Data Protection
2
+
3
+ This document describes **every piece of data Refrain touches**, where it
4
+ goes, why, and how to erase it. It is written to satisfy the
5
+ transparency expectations of the EU GDPR (Arts. 13–14) and equivalent
6
+ regimes, and as an honest plain-language overview for everyone.
7
+
8
+ ## TL;DR
9
+
10
+ - Refrain is a **local desktop application**. There is **no
11
+ Refrain-operated server, no account, and no telemetry**. The author
12
+ receives **nothing** and has no access to any of your data.
13
+ - Refrain does not profile you, does not make automated decisions about
14
+ you, shows no ads, and never sells or shares data.
15
+ - Data leaves your machine **only** when *you* enable an optional
16
+ feature, and then it goes **directly** to the third party that
17
+ feature integrates with (Apple, Discord, GitHub, Last.fm) under
18
+ *their* privacy policy and *your* relationship with them — never via
19
+ the author.
20
+ - Everything Refrain stores is on your own machine, in standard XDG
21
+ directories, and can be deleted at any time (see *Erasure*).
22
+
23
+ ## Who is the "controller"?
24
+
25
+ Because Refrain runs entirely on your computer with no backend, **you**
26
+ are effectively the controller of your own data. The Refrain author is
27
+ not a processor of your data — the author never receives it. Each
28
+ external transfer below is initiated solely by your own configuration
29
+ choice (your consent / your own purposes) and is then subject to the
30
+ receiving service's own privacy policy and your account there.
31
+
32
+ ## Data Refrain processes — and where it goes
33
+
34
+ ### 1. Stays on your machine, never transmitted by Refrain
35
+
36
+ | Data | Purpose | Location |
37
+ |---|---|---|
38
+ | Now-playing metadata (title, artist, album, position) read from your media player via D-Bus / BlueZ AVRCP | Drive the tray, notifications, the published MPRIS player | In memory; reflected in the tray |
39
+ | Listening history lines (track changes, scrobble events) | Diagnostics / live log | `$XDG_STATE_HOME/refrain/refrain.log` — rotating, ≤ 4 × 1 MB, plaintext, local only |
40
+ | Album-cover images + iTunes URL/duration cache | Avoid re-fetching covers | `$XDG_CACHE_HOME/refrain/` — capped (default 200), oldest pruned |
41
+ | Pending scrobbles (artist/track/album/timestamp) | Survive offline / restart until submitted to Last.fm | `$XDG_STATE_HOME/refrain/scrobble_queue.jsonl` — capped at 1000, `0600` semantics, removed once submitted |
42
+ | Preferences + the *public* Discord/Last.fm application IDs | Your settings | `$XDG_CONFIG_HOME/refrain/config.toml` — written **owner-only (`0600`)**; contains **no secrets** |
43
+ | Last.fm **shared secret** and **session token** | Authenticate scrobbling | **OS keyring** (KWallet / GNOME Keyring), encrypted at rest; or, only if no keyring exists, a `0600` owner-only `secrets.json`. **Never** in `config.toml`; never logged |
44
+
45
+ Refrain never transmits any of the above over a network on its own.
46
+
47
+ ### 2. Sent to a third party — only when you enable that feature
48
+
49
+ | Recipient | When | What is sent | Default | How to stop it |
50
+ |---|---|---|---|---|
51
+ | **Discord** (your local Discord client → Discord's servers) | Only if you set a Discord Application ID | Track title / artist / album, cover-art URL, optional "Listen on Apple Music" link | **Off** (no ID configured) | Leave the Client ID blank, or set *Privacy → Off*, or *Minimal* (only "Listening to music") |
52
+ | **Apple** (`itunes.apple.com` + artwork CDN, HTTPS) | If "Fetch album cover art" is on **and** a track with artist+title plays | The **artist + track name** (to look up cover/duration/song URL) and your IP / User-Agent; then an image fetch. No account, no auth, no cookies | **On** | Untick *Notifications → Fetch album cover art from iTunes* |
53
+ | **GitHub** (`api.github.com`, HTTPS) | If auto-update check is on: once per day on start, or a manual check | A `User-Agent` containing the Refrain version, and your IP. **No personal data, no account** | **On** | Untick *Updates → Automatically check on startup* |
54
+ | **Last.fm** (`ws.audioscrobbler.com` / `last.fm`, HTTPS) | Only if you enable scrobbling **and** connect an account | Artist / track / album / timestamp, your Last.fm API key + session token + a request signature | **Off** (opt-in) | Don't enable it; or *Disconnect*; or set *Privacy → Off* |
55
+
56
+ Notes:
57
+
58
+ - **Privacy modes** (*Settings → General → Privacy*): `Full` sends full
59
+ metadata to Discord; `Minimal` sends only "Listening to music";
60
+ `Off` is the global **no-external-broadcasting** kill switch — it
61
+ disables the Discord status **and** silences Last.fm scrobbling.
62
+ - The Apple cover-art lookup is gated by the *cover-art* toggle, which
63
+ is **independent of Privacy mode** — if you want zero network egress
64
+ even in `Off` mode, also untick "Fetch album cover art".
65
+ - Discord itself then displays/broadcasts your status to your Discord
66
+ contacts. That processing is Discord's, under your Discord account
67
+ and Discord's policy — Refrain only writes to the **local** Discord
68
+ IPC socket.
69
+
70
+ ### 3. Never collected at all
71
+
72
+ No analytics, no telemetry, no crash reporting, no advertising
73
+ identifiers, no device/hardware fingerprinting, no machine ID, no
74
+ contacts, no email, no location, no special-category data, no data
75
+ about children. There is nothing to opt out of because none of it
76
+ exists.
77
+
78
+ ## Legal basis (GDPR Art. 6)
79
+
80
+ Every external transfer above happens **only** because you enabled the
81
+ corresponding optional integration for your own purposes — i.e. your
82
+ **consent / your own legitimate use**. No processing relies on the
83
+ author having any legal basis, because the author processes nothing.
84
+
85
+ ## Recipients & international transfers
86
+
87
+ When you enable an integration, data goes directly to that provider,
88
+ which may process it outside your country under its own framework and
89
+ policy. Refrain has no influence over this. Review their policies:
90
+
91
+ - Apple — <https://www.apple.com/legal/privacy/>
92
+ - Discord — <https://discord.com/privacy>
93
+ - GitHub — <https://docs.github.com/site-policy/privacy-policies/github-general-privacy-statement>
94
+ - Last.fm — <https://www.last.fm/legal/privacy>
95
+
96
+ ## Security of processing (GDPR Art. 32)
97
+
98
+ - All network egress is **HTTPS** (the code refuses non-`https://`
99
+ endpoints for its lookups/updates).
100
+ - Credentials are stored in the **OS keyring, encrypted at rest**;
101
+ the fallback file and `config.toml` are written **owner-only
102
+ (`0600`)**. Secrets are **never written to logs** or to
103
+ `config.toml`, and a legacy plaintext secret from an older build is
104
+ auto-migrated into the keyring and scrubbed from disk.
105
+ - Refrain writes only inside `$XDG_CONFIG_HOME`, `$XDG_STATE_HOME`,
106
+ `$XDG_CACHE_HOME`.
107
+ - See [`SECURITY.md`](SECURITY.md) for the threat model and how to
108
+ report a vulnerability.
109
+
110
+ ## Retention & your control
111
+
112
+ - **Logs** rotate automatically (≤ 4 files of 1 MB); old data ages out.
113
+ - **Cover cache** is capped and pruned oldest-first.
114
+ - **Scrobble queue** entries are deleted once submitted; the queue is
115
+ capped at 1000.
116
+ - Everything else persists until you change or delete it.
117
+
118
+ ## Erasure ("right to be forgotten", locally)
119
+
120
+ To remove all data Refrain stored on your machine:
121
+
122
+ ```sh
123
+ rm -rf "${XDG_CONFIG_HOME:-$HOME/.config}/refrain" \
124
+ "${XDG_STATE_HOME:-$HOME/.local/state}/refrain" \
125
+ "${XDG_CACHE_HOME:-$HOME/.cache}/refrain"
126
+ ```
127
+
128
+ - **Last.fm credentials in the keyring:** click *Disconnect* in
129
+ *Settings → Last.fm* (then Apply), or delete the
130
+ `io.github.Rockykln.Refrain` entries in your keyring tool
131
+ (KWallet Manager / Seahorse).
132
+ - **Data already sent to a third party** (your Discord activity
133
+ history, your Last.fm scrobbles, etc.) is held by *that* provider —
134
+ exercise your access/erasure rights with them directly via their
135
+ links above (e.g. Last.fm lets you delete individual scrobbles or
136
+ your account).
137
+ - Autostart entry (if enabled):
138
+ `${XDG_CONFIG_HOME:-$HOME/.config}/autostart/refrain.desktop`.
139
+
140
+ ## Changes
141
+
142
+ Material changes to data handling are recorded in
143
+ [`CHANGELOG.md`](CHANGELOG.md) (notably under **Security**) and here.
144
+
145
+ ## Contact
146
+
147
+ - General / privacy questions → **contact@rockykln.com**
148
+ - Security reports → **report@rockykln.com** (see [`SECURITY.md`](SECURITY.md))
@@ -21,6 +21,8 @@ streaming from your iPhone over Bluetooth.
21
21
  - Reads playback metadata from **MPRIS** (Apple Music in any major Linux
22
22
  browser) and **BlueZ AVRCP** (any AVRCP-capable Bluetooth source).
23
23
  - Forwards track + cover art to Discord via the local IPC socket.
24
+ - Optionally **scrobbles to Last.fm** alongside Discord (opt-in, with a
25
+ crash-safe offline queue).
24
26
  - Lives in your **system tray** with Play/Pause/Next/Previous controls.
25
27
  - Provides a **settings window** (PySide6) for everything users typically want
26
28
  to tweak — privacy mode, sources, autostart, Bluetooth device picker.
@@ -163,6 +165,15 @@ log_level = "INFO"
163
165
  cover_cache_size = 200 # disk cap for cached covers
164
166
  idle_grace_s = 30 # clear status when same track plays past duration + grace; 0 disables
165
167
  language = "system" # "system" follows QLocale; "en", "de", "es", "fr", "pt", "it", "ru", "pl", "ja", "zh_CN" force a translation
168
+
169
+ [lastfm]
170
+ enabled = false # opt-in, alongside (never replacing) the Discord RPC
171
+ api_key = "" # register your own at last.fm/api/account/create
172
+ username = "" # display only
173
+ scrobble_now_playing = true # also send the ephemeral "now playing" indicator
174
+ # NOTE: the Last.fm shared secret and session key are credentials and
175
+ # are deliberately NOT stored here. They live in your OS keyring
176
+ # (KWallet / GNOME Keyring), encrypted at rest — see "Last.fm" below.
166
177
  ```
167
178
 
168
179
  Per-source `client_id_*` fields let Apple Music render under one Discord
@@ -261,14 +272,40 @@ Behavior is install-type-aware:
261
272
  own upgrade command (`flatpak update …` / `yay -Syu refrain`) so the
262
273
  package manager stays in charge.
263
274
 
275
+ ## Last.fm scrobbling
276
+
277
+ Refrain can scrobble to [Last.fm](https://www.last.fm) **alongside** the
278
+ Discord status — a second, independent channel, never a replacement.
279
+ It's **opt-in** and off by default.
280
+
281
+ Register your own free [API account](https://www.last.fm/api/account/create),
282
+ then open the *Settings → Last.fm* tab: tick **Enable**, paste the
283
+ **API key** + **shared secret**, click **Connect…** and approve the
284
+ browser prompt. Scrobbling starts on the next track — no restart.
285
+
286
+ The **shared secret and session token are stored in your OS keyring**
287
+ (KWallet / GNOME Keyring), encrypted at rest — never in `config.toml`
288
+ (which is itself written owner-only, `0600`). On a system with no
289
+ keyring they fall back to a `0600` file. Credentials only ever leave
290
+ the machine to Last.fm over HTTPS, which is what scrobbling *is*.
291
+
292
+ A track is scrobbled once you've played at least half of it, or four
293
+ minutes (Last.fm's rule), and only if it's longer than 30 s. Scrobbles
294
+ are queued to disk the instant they qualify, so being offline, a
295
+ Last.fm outage, or quitting mid-song never loses them — they submit on
296
+ the next opportunity. Privacy mode `Off` silences scrobbling too.
297
+
298
+ Full walkthrough + troubleshooting: [`docs/lastfm.md`](docs/lastfm.md).
299
+
264
300
  ## File locations
265
301
 
266
- | What | Where |
267
- |--------------|---------------------------------------------|
268
- | Config | `$XDG_CONFIG_HOME/refrain/config.toml` |
269
- | Logs | `$XDG_STATE_HOME/refrain/refrain.log` (rotates) |
270
- | Cover cache | `$XDG_CACHE_HOME/refrain/covers/*.txt` |
271
- | Autostart | `$XDG_CONFIG_HOME/autostart/refrain.desktop` (when enabled) |
302
+ | What | Where |
303
+ |---------------|---------------------------------------------|
304
+ | Config | `$XDG_CONFIG_HOME/refrain/config.toml` |
305
+ | Scrobble queue| `$XDG_STATE_HOME/refrain/scrobble_queue.jsonl` |
306
+ | Logs | `$XDG_STATE_HOME/refrain/refrain.log` (rotates) |
307
+ | Cover cache | `$XDG_CACHE_HOME/refrain/covers/*.txt` |
308
+ | Autostart | `$XDG_CONFIG_HOME/autostart/refrain.desktop` (when enabled) |
272
309
 
273
310
  ## Diagnostics — live log
274
311
 
@@ -283,22 +320,38 @@ without tailing it from a terminal.
283
320
 
284
321
  ## Privacy
285
322
 
286
- The cover-art lookup hits Apple's public iTunes Search API over HTTPS with
287
- just the artist + track name. Nothing else leaves your machine — track
288
- metadata goes to Discord's local IPC socket, not over the network.
323
+ Refrain is **local-first**: no Refrain server, no account, **no
324
+ telemetry**, and the author receives nothing. Data leaves your machine
325
+ **only** when *you* enable an optional integration, and then it goes
326
+ directly to that provider:
327
+
328
+ - **Discord** — only if you set a Discord Application ID; track
329
+ metadata goes to the *local* Discord IPC socket (the Discord client
330
+ then broadcasts it under your account).
331
+ - **Apple iTunes Search** (HTTPS) — artist + track name, only while
332
+ cover-art is enabled, to fetch album art. Untick it for zero egress.
333
+ - **GitHub** (HTTPS) — a daily update check sends only the Refrain
334
+ version + your IP. Disable in *Updates*.
335
+ - **Last.fm** (HTTPS) — opt-in scrobbling only; credentials live in
336
+ your **OS keyring** (encrypted at rest), never in `config.toml`.
337
+
338
+ `Privacy → Off` is the global kill switch (no Discord status, no
339
+ scrobbling) while keeping the tray + controls running.
289
340
 
290
- `Off` privacy mode disables the Discord status entirely while keeping the
291
- tray + controls running.
341
+ Full data-flow, retention and erasure details — written to GDPR
342
+ transparency expectations — are in [`PRIVACY.md`](PRIVACY.md).
292
343
 
293
344
  ## Documentation
294
345
 
295
346
  - [Architecture overview](docs/architecture.md) — threads, D-Bus surface, file paths
296
347
  - [FAQ](docs/faq.md)
297
348
  - [Bluetooth quick-start](docs/bluetooth.md) — pair + AVRCP setup walkthrough
349
+ - [Last.fm scrobbling](docs/lastfm.md) — API account + connect walkthrough
298
350
  - [Test matrix](docs/test-matrix.md) — supported distros, smoke-check checklist
299
351
  - [Roadmap](ROADMAP.md)
300
352
  - [Changelog](CHANGELOG.md)
301
353
  - [Contributing](CONTRIBUTING.md) — dev setup, testing, code style
354
+ - [Privacy & data protection](PRIVACY.md) — every data flow, retention, erasure
302
355
  - [Security policy](SECURITY.md)
303
356
  - [Packaging guide](packaging/README.md) — AUR, Flatpak, AppImage build steps
304
357