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.
- platterpus/__init__.py +11 -0
- platterpus/__main__.py +11 -0
- platterpus/adapters/__init__.py +14 -0
- platterpus/adapters/accuraterip_offsets.py +239 -0
- platterpus/adapters/accuraterip_offsets_data.py +386 -0
- platterpus/adapters/cover_art.py +190 -0
- platterpus/adapters/ctdb_client.py +177 -0
- platterpus/adapters/cyanrip_backend.py +443 -0
- platterpus/adapters/flac_recompress.py +170 -0
- platterpus/adapters/flac_verify.py +104 -0
- platterpus/adapters/metaflac.py +128 -0
- platterpus/adapters/musicbrainz_client.py +373 -0
- platterpus/adapters/transcode.py +228 -0
- platterpus/adapters/whipper_backend.py +673 -0
- platterpus/app.py +266 -0
- platterpus/app_icon.py +44 -0
- platterpus/appimage_integration.py +297 -0
- platterpus/composition.py +82 -0
- platterpus/config.py +316 -0
- platterpus/ctdb/__init__.py +12 -0
- platterpus/ctdb/crc.py +47 -0
- platterpus/ctdb/decode.py +124 -0
- platterpus/ctdb/toc.py +133 -0
- platterpus/ctdb/verify.py +158 -0
- platterpus/deps/__init__.py +14 -0
- platterpus/deps/checks.py +262 -0
- platterpus/deps/host_setup.py +477 -0
- platterpus/deps/host_teardown.py +324 -0
- platterpus/deps/manager.py +195 -0
- platterpus/deps/registry.py +226 -0
- platterpus/deps/resolvers.py +242 -0
- platterpus/deps/step_engine.py +114 -0
- platterpus/deps/version.py +86 -0
- platterpus/drive_access.py +177 -0
- platterpus/drive_control.py +250 -0
- platterpus/drive_profile_store.py +241 -0
- platterpus/drive_profiles.py +391 -0
- platterpus/eac_log_export.py +177 -0
- platterpus/goal_presets.py +109 -0
- platterpus/help_content.py +207 -0
- platterpus/logging_setup.py +105 -0
- platterpus/offset_config.py +146 -0
- platterpus/parity.py +125 -0
- platterpus/parsers/__init__.py +11 -0
- platterpus/parsers/cd_info.py +85 -0
- platterpus/parsers/cyanrip_info.py +98 -0
- platterpus/parsers/cyanrip_log.py +220 -0
- platterpus/parsers/drive_list.py +109 -0
- platterpus/parsers/eac_log.py +62 -0
- platterpus/parsers/rip_log.py +410 -0
- platterpus/paths.py +79 -0
- platterpus/preflight.py +715 -0
- platterpus/resources/platterpus-logo.svg +26 -0
- platterpus/rip_report.py +179 -0
- platterpus/ui/__init__.py +6 -0
- platterpus/ui/dialogs/__init__.py +5 -0
- platterpus/ui/dialogs/manual_install.py +183 -0
- platterpus/ui/dialogs/pending_installs.py +245 -0
- platterpus/ui/disc_info_panel.py +183 -0
- platterpus/ui/drive_picker.py +221 -0
- platterpus/ui/drive_setup_dialog.py +310 -0
- platterpus/ui/help_dialogs.py +101 -0
- platterpus/ui/host_setup_dialog.py +218 -0
- platterpus/ui/main_window.py +719 -0
- platterpus/ui/main_window_deps.py +339 -0
- platterpus/ui/main_window_drive.py +401 -0
- platterpus/ui/main_window_helpers.py +162 -0
- platterpus/ui/main_window_provision.py +267 -0
- platterpus/ui/main_window_rip.py +968 -0
- platterpus/ui/main_window_update.py +306 -0
- platterpus/ui/release_picker.py +149 -0
- platterpus/ui/rip_controls.py +185 -0
- platterpus/ui/rip_progress.py +349 -0
- platterpus/ui/settings_dialog.py +664 -0
- platterpus/ui/track_table.py +316 -0
- platterpus/ui/uninstall_dialog.py +235 -0
- platterpus/ui/unknown_album.py +206 -0
- platterpus/update_check.py +91 -0
- platterpus/update_install.py +148 -0
- platterpus/verdict.py +79 -0
- platterpus/workers/__init__.py +49 -0
- platterpus/workers/ctdb_worker.py +82 -0
- platterpus/workers/dependency_worker.py +47 -0
- platterpus/workers/disc_info_worker.py +56 -0
- platterpus/workers/drive_list_worker.py +51 -0
- platterpus/workers/drive_setup_worker.py +146 -0
- platterpus/workers/flac_verify_worker.py +55 -0
- platterpus/workers/host_setup_worker.py +61 -0
- platterpus/workers/mb_worker.py +90 -0
- platterpus/workers/rip_worker.py +485 -0
- platterpus/workers/update_worker.py +79 -0
- platterpus-0.4.0.dist-info/METADATA +883 -0
- platterpus-0.4.0.dist-info/RECORD +97 -0
- platterpus-0.4.0.dist-info/WHEEL +5 -0
- platterpus-0.4.0.dist-info/entry_points.txt +2 -0
- platterpus-0.4.0.dist-info/licenses/LICENSE +232 -0
- 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
|