platterpus 0.4.0__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.
Files changed (97) hide show
  1. platterpus/__init__.py +11 -0
  2. platterpus/__main__.py +11 -0
  3. platterpus/adapters/__init__.py +14 -0
  4. platterpus/adapters/accuraterip_offsets.py +239 -0
  5. platterpus/adapters/accuraterip_offsets_data.py +386 -0
  6. platterpus/adapters/cover_art.py +190 -0
  7. platterpus/adapters/ctdb_client.py +177 -0
  8. platterpus/adapters/cyanrip_backend.py +443 -0
  9. platterpus/adapters/flac_recompress.py +170 -0
  10. platterpus/adapters/flac_verify.py +104 -0
  11. platterpus/adapters/metaflac.py +128 -0
  12. platterpus/adapters/musicbrainz_client.py +373 -0
  13. platterpus/adapters/transcode.py +228 -0
  14. platterpus/adapters/whipper_backend.py +673 -0
  15. platterpus/app.py +266 -0
  16. platterpus/app_icon.py +44 -0
  17. platterpus/appimage_integration.py +297 -0
  18. platterpus/composition.py +82 -0
  19. platterpus/config.py +316 -0
  20. platterpus/ctdb/__init__.py +12 -0
  21. platterpus/ctdb/crc.py +47 -0
  22. platterpus/ctdb/decode.py +124 -0
  23. platterpus/ctdb/toc.py +133 -0
  24. platterpus/ctdb/verify.py +158 -0
  25. platterpus/deps/__init__.py +14 -0
  26. platterpus/deps/checks.py +262 -0
  27. platterpus/deps/host_setup.py +477 -0
  28. platterpus/deps/host_teardown.py +324 -0
  29. platterpus/deps/manager.py +195 -0
  30. platterpus/deps/registry.py +226 -0
  31. platterpus/deps/resolvers.py +242 -0
  32. platterpus/deps/step_engine.py +114 -0
  33. platterpus/deps/version.py +86 -0
  34. platterpus/drive_access.py +177 -0
  35. platterpus/drive_control.py +250 -0
  36. platterpus/drive_profile_store.py +241 -0
  37. platterpus/drive_profiles.py +391 -0
  38. platterpus/eac_log_export.py +177 -0
  39. platterpus/goal_presets.py +109 -0
  40. platterpus/help_content.py +207 -0
  41. platterpus/logging_setup.py +105 -0
  42. platterpus/offset_config.py +146 -0
  43. platterpus/parity.py +125 -0
  44. platterpus/parsers/__init__.py +11 -0
  45. platterpus/parsers/cd_info.py +85 -0
  46. platterpus/parsers/cyanrip_info.py +98 -0
  47. platterpus/parsers/cyanrip_log.py +220 -0
  48. platterpus/parsers/drive_list.py +109 -0
  49. platterpus/parsers/eac_log.py +62 -0
  50. platterpus/parsers/rip_log.py +410 -0
  51. platterpus/paths.py +79 -0
  52. platterpus/preflight.py +715 -0
  53. platterpus/resources/platterpus-logo.svg +26 -0
  54. platterpus/rip_report.py +179 -0
  55. platterpus/ui/__init__.py +6 -0
  56. platterpus/ui/dialogs/__init__.py +5 -0
  57. platterpus/ui/dialogs/manual_install.py +183 -0
  58. platterpus/ui/dialogs/pending_installs.py +245 -0
  59. platterpus/ui/disc_info_panel.py +183 -0
  60. platterpus/ui/drive_picker.py +221 -0
  61. platterpus/ui/drive_setup_dialog.py +310 -0
  62. platterpus/ui/help_dialogs.py +101 -0
  63. platterpus/ui/host_setup_dialog.py +218 -0
  64. platterpus/ui/main_window.py +719 -0
  65. platterpus/ui/main_window_deps.py +339 -0
  66. platterpus/ui/main_window_drive.py +401 -0
  67. platterpus/ui/main_window_helpers.py +162 -0
  68. platterpus/ui/main_window_provision.py +267 -0
  69. platterpus/ui/main_window_rip.py +968 -0
  70. platterpus/ui/main_window_update.py +306 -0
  71. platterpus/ui/release_picker.py +149 -0
  72. platterpus/ui/rip_controls.py +185 -0
  73. platterpus/ui/rip_progress.py +349 -0
  74. platterpus/ui/settings_dialog.py +664 -0
  75. platterpus/ui/track_table.py +316 -0
  76. platterpus/ui/uninstall_dialog.py +235 -0
  77. platterpus/ui/unknown_album.py +206 -0
  78. platterpus/update_check.py +91 -0
  79. platterpus/update_install.py +148 -0
  80. platterpus/verdict.py +79 -0
  81. platterpus/workers/__init__.py +49 -0
  82. platterpus/workers/ctdb_worker.py +82 -0
  83. platterpus/workers/dependency_worker.py +47 -0
  84. platterpus/workers/disc_info_worker.py +56 -0
  85. platterpus/workers/drive_list_worker.py +51 -0
  86. platterpus/workers/drive_setup_worker.py +146 -0
  87. platterpus/workers/flac_verify_worker.py +55 -0
  88. platterpus/workers/host_setup_worker.py +61 -0
  89. platterpus/workers/mb_worker.py +90 -0
  90. platterpus/workers/rip_worker.py +485 -0
  91. platterpus/workers/update_worker.py +79 -0
  92. platterpus-0.4.0.dist-info/METADATA +883 -0
  93. platterpus-0.4.0.dist-info/RECORD +97 -0
  94. platterpus-0.4.0.dist-info/WHEEL +5 -0
  95. platterpus-0.4.0.dist-info/entry_points.txt +2 -0
  96. platterpus-0.4.0.dist-info/licenses/LICENSE +232 -0
  97. platterpus-0.4.0.dist-info/top_level.txt +1 -0
platterpus/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Platterpus — Linux GUI front-end for the whipper audio-CD ripping CLI."""
2
+
3
+ # ── Canonical version: the single source of truth ──────────────────────────
4
+ # This string is THE version of Platterpus. Bump it here and nowhere else:
5
+ # * the build reads it via `[tool.setuptools.dynamic]` in pyproject.toml, so
6
+ # the installed package metadata (`importlib.metadata.version`) matches;
7
+ # * the running app imports it (`from platterpus import __version__`) for
8
+ # the `--version` flag, the Help → About dialog, and the MusicBrainz
9
+ # user-agent.
10
+ # To cut a release: bump this, add a CHANGELOG entry, then tag `vX.Y.Z`.
11
+ __version__: str = "0.4.0"
platterpus/__main__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Entry point for `python -m platterpus` and the `platterpus` console script.
2
+
3
+ Kept deliberately tiny so packaging tools (pipx, python-appimage) and the
4
+ AppImage's AppRun script have a single stable target to invoke. All real
5
+ startup logic lives in `platterpus.app.main`.
6
+ """
7
+
8
+ from platterpus.app import main
9
+
10
+ if __name__ == "__main__":
11
+ raise SystemExit(main())
@@ -0,0 +1,14 @@
1
+ """Adapter layer over external dependencies.
2
+
3
+ Per CLAUDE.md Critical Rule #1, every call into an unmaintained
4
+ dependency goes through a thin adapter so a future replacement is
5
+ feasible without rewriting the GUI. The adapters in this package:
6
+
7
+ - `whipper_backend` — wraps the host-exported `whipper` CLI. Replacement
8
+ target if needed: `cyanrip`.
9
+ - `musicbrainz_client` — wraps `musicbrainzngs`. Replacement target:
10
+ direct `requests` against MusicBrainz's JSON REST endpoint.
11
+ - `metaflac` — wraps the `metaflac` CLI from the FLAC project. Not on
12
+ the unmaintained list, but kept consistent with the adapter pattern
13
+ so subprocess details stay out of the GUI.
14
+ """
@@ -0,0 +1,239 @@
1
+ """Adapter for the AccurateRip drive read-offset list.
2
+
3
+ Why this exists
4
+ ---------------
5
+ whipper's own ``offset find`` is documented upstream as "primitive": it
6
+ rips trial offsets and compares them against AccurateRip *for the inserted
7
+ disc*, inside the Distrobox container. In practice it fails often — it
8
+ failed on a Pioneer BDR-209D even with a disc that IS in AccurateRip.
9
+
10
+ EAC and dBpoweramp don't probe a disc to learn the read offset at all.
11
+ They look it up by **drive model** in AccurateRip's published drive-offset
12
+ list. We already have the drive's vendor + model from ``whipper drive
13
+ list`` (``DriveDescriptor``), so we can resolve the correct offset with no
14
+ disc, no network round-trip, and no dependence on whipper's flaky probe.
15
+
16
+ Critical Rule #1 (adapters): AccurateRip's list is an external data source,
17
+ so access goes through this module. The bundled ``_CURATED_OFFSETS`` table
18
+ is a small, high-confidence subset kept **in code** (not as packaged data)
19
+ to dodge the AppImage package-data pitfalls that bit ``help_content``. A
20
+ user can extend/override it by dropping a CSV at
21
+ ``~/.config/platterpus/drive_offsets.csv`` (``name,offset`` rows) — that's
22
+ the path to the full official list without a code change. See
23
+ docs/archive/offset-investigation-2026-06.md.
24
+
25
+ Safety: a wrong offset silently corrupts a rip, so this adapter only ever
26
+ *suggests* a value — the wizard prefills it and the user confirms (and can
27
+ cross-check against accuraterip.com). Nothing here writes config or rips.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import logging
33
+ import re
34
+ from pathlib import Path
35
+
36
+ from platterpus.paths import CONFIG_DIR
37
+
38
+ log = logging.getLogger(__name__)
39
+
40
+ # A user-supplied list (the full AccurateRip export, or hand additions),
41
+ # overlaid on top of the curated table. Same simple ``name,offset`` CSV
42
+ # shape the curated table uses once normalized.
43
+ USER_OFFSETS_PATH: Path = CONFIG_DIR / "drive_offsets.csv"
44
+
45
+
46
+ def canonical_token(s: str) -> str:
47
+ """Uppercase, collapse internal whitespace, strip — the shared core.
48
+
49
+ This is the *one* canonicalization primitive used everywhere a drive
50
+ identity string becomes a stable key: the AccurateRip lookup key here AND
51
+ the drive-profile fingerprint in ``drive_profiles.py``. Keeping it in one
52
+ place means those keys can never silently disagree. Changing it is
53
+ load-bearing — it would invalidate every stored fingerprint (which the
54
+ profile store treats fail-safe: an unknown fingerprint just prompts a fresh
55
+ confirm, never a wrong offset).
56
+ """
57
+ return re.sub(r"\s+", " ", s).strip().upper()
58
+
59
+
60
+ def normalize_combined(combined: str) -> str:
61
+ """Canonicalize an already-joined ``"<vendor> <model>"`` string.
62
+
63
+ Drops a leading ``ATAPI`` tag some drives prepend, collapses AccurateRip's
64
+ ``" - "`` vendor/model separator (whipper reports the two as separate
65
+ fields with no dash) and a leading ``"- "`` (vendorless entries), then
66
+ applies :func:`canonical_token`. Split out from :func:`normalize_drive_name`
67
+ so a *combined* string from another source — e.g. whipper.conf's decoded
68
+ ``[drive:VENDOR%20MODEL]`` section id — can be canonicalized the same way.
69
+ """
70
+ # Some drives report "ATAPI iHAS124 B" etc.; the ATAPI tag isn't
71
+ # part of AccurateRip's name.
72
+ combined = re.sub(r"^\s*ATAPI\b", " ", combined, flags=re.IGNORECASE)
73
+ # AccurateRip's "<vendor> - <model>" separator (spaces around a hyphen).
74
+ # In-token hyphens like BD-RW / BDR-209D have no surrounding spaces, so
75
+ # they're untouched.
76
+ combined = re.sub(r"\s+-\s+", " ", combined)
77
+ combined = re.sub(r"^\s*-\s+", "", combined) # vendorless: leading "- "
78
+ return canonical_token(combined)
79
+
80
+
81
+ def normalize_drive_name(vendor: str, model: str) -> str:
82
+ """Canonicalize a drive's vendor+model into a single lookup key.
83
+
84
+ Both AccurateRip's list and whipper derive the name from the drive's
85
+ ATA/SCSI IDENTIFY strings, so they agree once whitespace and case are
86
+ normalized. whipper notably emits double-spaced models (Pioneer's real
87
+ output is ``BD-RW BDR-209D``), so collapsing whitespace is essential.
88
+ AccurateRip stores e.g. ``"PIONEER - BD-RW BDR-209D"`` while whipper
89
+ reports vendor ``"PIONEER"`` + model ``"BD-RW BDR-209D"`` — after this
90
+ both become ``"PIONEER BD-RW BDR-209D"``.
91
+ """
92
+ return normalize_combined(f"{vendor} {model}")
93
+
94
+
95
+ # --- Curated, high-confidence offsets ---------------------------------------
96
+ #
97
+ # Keys are already normalized (see normalize_drive_name). Deliberately small:
98
+ # shipping a WRONG offset corrupts rips, so we include only widely-published,
99
+ # stable values — led by the Pioneer BD/DVD family this project is tested on
100
+ # (BDR-209D = +667 is user-confirmed real hardware). The full ~80k-row
101
+ # AccurateRip list is imported via the user CSV, not hard-coded here.
102
+ _CURATED_OFFSETS: dict[str, int] = {
103
+ # Pioneer BD writers share the +667 read offset (tested: BDR-209D).
104
+ "PIONEER BD-RW BDR-209D": 667,
105
+ "PIONEER BD-RW BDR-209M": 667,
106
+ "PIONEER BD-RW BDR-209U": 667,
107
+ "PIONEER BD-RW BDR-S09": 667,
108
+ "PIONEER BD-RW BDR-2090": 667,
109
+ # Pioneer DVD writers (the classic DVR family) read at +48.
110
+ "PIONEER DVD-RW DVR-220L": 48,
111
+ # A couple of long-stable, very widely-cited values.
112
+ "PLEXTOR CD-R PREMIUM": 30,
113
+ "PLEXTOR DVDR PX-716A": 30,
114
+ }
115
+
116
+
117
+ class OffsetDatabase:
118
+ """Maps a drive's vendor+model to its AccurateRip read offset.
119
+
120
+ Construct via :meth:`load_default` for the bundled table overlaid with
121
+ the user's CSV, or pass an explicit ``entries`` dict in tests.
122
+ """
123
+
124
+ def __init__(self, entries: dict[str, int]) -> None:
125
+ # Keys are assumed already normalized.
126
+ self._entries: dict[str, int] = dict(entries)
127
+
128
+ @property
129
+ def size(self) -> int:
130
+ return len(self._entries)
131
+
132
+ @classmethod
133
+ def load_default(cls, user_path: Path = USER_OFFSETS_PATH) -> OffsetDatabase:
134
+ """The full bundled AccurateRip list, overlaid with curated fixes and
135
+ the user CSV (in that precedence: user > curated > bundled).
136
+
137
+ The bundled list (`accuraterip_offsets_data`, ~4.8k drives) covers
138
+ essentially every drive offline. `_CURATED_OFFSETS` is a tiny set of
139
+ hand-verified values that can override a bundled entry; the user CSV
140
+ overrides everything.
141
+ """
142
+ entries = _load_bundled()
143
+ entries.update(_CURATED_OFFSETS)
144
+ entries.update(_load_user_csv(user_path))
145
+ return cls(entries)
146
+
147
+ def lookup(self, vendor: str, model: str) -> int | None:
148
+ """Return the known read offset for this drive, or None if unknown.
149
+
150
+ Never raises — an unknown drive is a normal outcome the caller
151
+ handles by falling back to disc-based detection or manual entry.
152
+ """
153
+ if not vendor and not model:
154
+ return None
155
+ key = normalize_drive_name(vendor, model)
156
+ if key in self._entries:
157
+ return self._entries[key]
158
+ # Fallback: AccurateRip sometimes omits/duplicates the vendor token.
159
+ # Try matching on the model tail (everything after the first token)
160
+ # against keys' tails, but only when it's an unambiguous single hit,
161
+ # so we never guess between two different drives.
162
+ model_key = normalize_drive_name("", model)
163
+ if model_key:
164
+ matches = {v for k, v in self._entries.items() if k.endswith(model_key)}
165
+ if len(matches) == 1:
166
+ return next(iter(matches))
167
+ return None
168
+
169
+
170
+ # --- Bundled full list ------------------------------------------------------
171
+
172
+
173
+ def _load_bundled() -> dict[str, int]:
174
+ """Decode the bundled AccurateRip list (gzip+base64 in-code blob).
175
+
176
+ Keys are already normalized at generation time (same `normalize_drive_name`
177
+ the lookup uses), so loading is just decompress + split. Returns an empty
178
+ dict if the data module is somehow unavailable — the curated table then
179
+ still covers the tested hardware.
180
+ """
181
+ try:
182
+ import base64
183
+ import gzip
184
+
185
+ from platterpus.adapters import accuraterip_offsets_data as _data
186
+
187
+ csv = gzip.decompress(base64.b64decode(_data._BLOB)).decode("utf-8")
188
+ except Exception: # noqa: BLE001 — never let a bad blob break drive setup
189
+ log.exception("could not load bundled drive-offset list")
190
+ return {}
191
+
192
+ entries: dict[str, int] = {}
193
+ for line in csv.splitlines():
194
+ key, _, value = line.partition(",")
195
+ if key and value:
196
+ try:
197
+ entries[key] = int(value)
198
+ except ValueError:
199
+ continue
200
+ return entries
201
+
202
+
203
+ # --- CSV loading ------------------------------------------------------------
204
+
205
+ _CSV_LINE = re.compile(r"^\s*(?P<name>.+?)\s*,\s*(?P<offset>-?\d+)\s*$")
206
+
207
+
208
+ def _load_user_csv(path: Path) -> dict[str, int]:
209
+ """Parse a user ``name,offset`` CSV into normalized entries.
210
+
211
+ Tolerant by design (it's user-edited): blank lines, ``#`` comments, a
212
+ header row, and malformed lines are skipped with a log note rather than
213
+ raising — a broken row must never break drive setup.
214
+ """
215
+ try:
216
+ text = path.read_text(encoding="utf-8")
217
+ except FileNotFoundError:
218
+ return {}
219
+ except OSError as exc:
220
+ log.warning("could not read drive-offset CSV %s: %s", path, exc)
221
+ return {}
222
+
223
+ entries: dict[str, int] = {}
224
+ for raw in text.splitlines():
225
+ line = raw.strip()
226
+ if not line or line.startswith("#"):
227
+ continue
228
+ match = _CSV_LINE.match(line)
229
+ if not match:
230
+ continue
231
+ name = match.group("name")
232
+ if name.lower() in ("name", "drive"): # header row
233
+ continue
234
+ # The name column is a full drive name; normalize with empty vendor
235
+ # so it collapses whitespace/case the same way lookups do.
236
+ entries[normalize_drive_name("", name)] = int(match.group("offset"))
237
+ if entries:
238
+ log.info("loaded %d drive offsets from %s", len(entries), path)
239
+ return entries