beets-clapback 0.1.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.
@@ -0,0 +1,45 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ .venv/
8
+ venv/
9
+ ENV/
10
+ .eggs/
11
+ *.egg-info/
12
+ *.egg
13
+
14
+ # uv — `ADR-0005` point 7. The workspace has exactly one lock, at the root, and it
15
+ # is committed. A lock never ships in a wheel: what `clapback-embed`'s dependents
16
+ # resolve comes from its `dependencies` ranges, so committing this cannot pin them.
17
+ # What it does pin is development, CI and the deployed image, which previously
18
+ # resolved fresh on every Docker build.
19
+
20
+ # IDE
21
+ .idea/
22
+ .vscode/
23
+ *.swp
24
+ *.swo
25
+
26
+ # Testing
27
+ .pytest_cache/
28
+ .coverage
29
+ htmlcov/
30
+
31
+ # Docker
32
+ data/
33
+
34
+ # Environment
35
+ .env
36
+ .env.local
37
+
38
+ # OS
39
+ .DS_Store
40
+ Thumbs.db
41
+
42
+ # Package venvs and exported model artifacts (614 MB, produced by scripts/export_models.py)
43
+ packages/*/.venv/
44
+ *.onnx
45
+ *.onnx.data
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jeff Crouse
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.5
2
+ Name: beets-clapback
3
+ Version: 0.1.0
4
+ Summary: beets plugin: look up, embed, and contribute CLAP embeddings to the clapback commons — absubmit reborn
5
+ Project-URL: Homepage, https://clapback.seethroughlab.com
6
+ Project-URL: Repository, https://github.com/seethroughlab/clapback
7
+ Project-URL: Decisions, https://github.com/seethroughlab/clapback/tree/main/docs/decisions
8
+ Author-email: Jeff Crouse <jeff@seethroughlab.com>
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Jeff Crouse
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: acoustid,audio,beets,clap,embeddings,music
32
+ Classifier: Development Status :: 4 - Beta
33
+ Classifier: Intended Audience :: End Users/Desktop
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Programming Language :: Python :: 3.11
37
+ Classifier: Programming Language :: Python :: 3.12
38
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
39
+ Requires-Python: >=3.11
40
+ Requires-Dist: beets>=2.0
41
+ Requires-Dist: clapback-client<0.2,>=0.1.0
42
+ Requires-Dist: clapback-embed<0.2,>=0.1.0
43
+ Provides-Extra: dev
44
+ Requires-Dist: pyacoustid>=1.3; extra == 'dev'
45
+ Requires-Dist: pytest>=7.4.0; extra == 'dev'
46
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
47
+ Description-Content-Type: text/markdown
48
+
49
+ # beets-clapback
50
+
51
+ Look up, embed, and — if you say so — contribute CLAP embeddings for your beets library to the
52
+ [clapback](https://clapback.seethroughlab.com) commons. `absubmit` reborn, with a payload that can
53
+ actually be reconciled across contributors.
54
+
55
+ ```bash
56
+ pip install beets-clapback
57
+ ```
58
+
59
+ ```yaml
60
+ plugins: chroma clapback
61
+
62
+ clapback:
63
+ contribute: no # opt-in. Turn it on when you have read "what leaves the machine".
64
+ ```
65
+
66
+ ```bash
67
+ beet clapback # every track: look up, else embed; contribute if enabled
68
+ beet clapback artist:Autechre # any beets query
69
+ beet clapback -p # say what would happen, touch nothing
70
+ beet clapback-search "dreamy ambient with piano"
71
+ ```
72
+
73
+ ## What it does
74
+
75
+ For each track: hash the AcoustID fingerprint the `chroma` plugin already stored (or compute one),
76
+ ask the commons whether it holds an embedding for that recording from the reference pipeline, and
77
+ if so take it — the model does not run. If not, embed locally with
78
+ [`clapback-embed`](https://pypi.org/project/clapback-embed/), keep the vector, and contribute it
79
+ back only when `contribute: yes`.
80
+
81
+ Two flexible attributes land on each item, so you can query them like anything else:
82
+
83
+ | field | values |
84
+ |---|---|
85
+ | `clapback_hash` | the corpus key — SHA256 of the fingerprint |
86
+ | `clapback_status` | `found` · `contributed` · `local` · `unfingerprinted` · `unembedded` |
87
+
88
+ ```bash
89
+ beet ls clapback_status:local # embedded here, not yet in the commons
90
+ beet ls -a clapback_status:found # albums the commons already had
91
+ ```
92
+
93
+ The vectors live in `clapback/` under your beets config directory, so `clapback-search` works
94
+ offline over everything embedded or fetched. Delete that directory and nothing is lost but time.
95
+
96
+ ## What it needs
97
+
98
+ - **`chroma`** enabled, or `chromaprint` installed so this plugin can fingerprint itself. `chroma`
99
+ is the better answer — it stores the fingerprint once and every plugin benefits.
100
+ - **The ONNX encoders** for `clapback-embed`, 614 MB, not bundled:
101
+ ```bash
102
+ pip install 'clapback-embed[export]'
103
+ python -m clapback_embed.scripts.export_models --out ~/.cache/clapback/models
104
+ ```
105
+ Without them, lookups still work; embedding does not, and the plugin says so.
106
+
107
+ ## What leaves the machine
108
+
109
+ Nothing until `contribute: yes`. Then, per track the commons did not already hold: a 512-float
110
+ vector and a one-way hash. **Never audio, never paths, never tags.** The hash cannot be reversed
111
+ into the fingerprint, and the fingerprint is not the audio.
112
+
113
+ The first contribution mints a random `client_id` — a UUID, derived from nothing about you or
114
+ your machine — and stores it at `clapback/client_id` under your config directory. It lets the
115
+ commons tell two contributions apart from one client retrying. Delete it and you are a new
116
+ contributor; nothing else changes.
117
+
118
+ ## Options
119
+
120
+ ```yaml
121
+ clapback:
122
+ url: https://clapback.seethroughlab.com
123
+ contribute: no # send vectors the commons lacks
124
+ auto: no # run on every import (embedding is minutes per album; off by default)
125
+ pace: 0.15 # seconds between contributions
126
+ ```
127
+
128
+ ## Why this exists
129
+
130
+ beets shipped `acousticbrainz` and `absubmit` for years. Both were deprecated when AcousticBrainz
131
+ shut down, and the suggested replacement was to compute locally and keep the results. This is the
132
+ other half back — with a difference that matters: AcousticBrainz pooled *estimates* (bpm, key,
133
+ mood) that could never be reconciled between contributors. An embedding from a pinned pipeline
134
+ can be. When two people contribute the same recording, the commons can say whether they agree.
135
+ The reasoning is in
136
+ [`ADR-0011`](https://github.com/seethroughlab/clapback/blob/main/docs/decisions/ADR-0011-the-commons-is-what-other-tools-plug-into.md).
@@ -0,0 +1,88 @@
1
+ # beets-clapback
2
+
3
+ Look up, embed, and — if you say so — contribute CLAP embeddings for your beets library to the
4
+ [clapback](https://clapback.seethroughlab.com) commons. `absubmit` reborn, with a payload that can
5
+ actually be reconciled across contributors.
6
+
7
+ ```bash
8
+ pip install beets-clapback
9
+ ```
10
+
11
+ ```yaml
12
+ plugins: chroma clapback
13
+
14
+ clapback:
15
+ contribute: no # opt-in. Turn it on when you have read "what leaves the machine".
16
+ ```
17
+
18
+ ```bash
19
+ beet clapback # every track: look up, else embed; contribute if enabled
20
+ beet clapback artist:Autechre # any beets query
21
+ beet clapback -p # say what would happen, touch nothing
22
+ beet clapback-search "dreamy ambient with piano"
23
+ ```
24
+
25
+ ## What it does
26
+
27
+ For each track: hash the AcoustID fingerprint the `chroma` plugin already stored (or compute one),
28
+ ask the commons whether it holds an embedding for that recording from the reference pipeline, and
29
+ if so take it — the model does not run. If not, embed locally with
30
+ [`clapback-embed`](https://pypi.org/project/clapback-embed/), keep the vector, and contribute it
31
+ back only when `contribute: yes`.
32
+
33
+ Two flexible attributes land on each item, so you can query them like anything else:
34
+
35
+ | field | values |
36
+ |---|---|
37
+ | `clapback_hash` | the corpus key — SHA256 of the fingerprint |
38
+ | `clapback_status` | `found` · `contributed` · `local` · `unfingerprinted` · `unembedded` |
39
+
40
+ ```bash
41
+ beet ls clapback_status:local # embedded here, not yet in the commons
42
+ beet ls -a clapback_status:found # albums the commons already had
43
+ ```
44
+
45
+ The vectors live in `clapback/` under your beets config directory, so `clapback-search` works
46
+ offline over everything embedded or fetched. Delete that directory and nothing is lost but time.
47
+
48
+ ## What it needs
49
+
50
+ - **`chroma`** enabled, or `chromaprint` installed so this plugin can fingerprint itself. `chroma`
51
+ is the better answer — it stores the fingerprint once and every plugin benefits.
52
+ - **The ONNX encoders** for `clapback-embed`, 614 MB, not bundled:
53
+ ```bash
54
+ pip install 'clapback-embed[export]'
55
+ python -m clapback_embed.scripts.export_models --out ~/.cache/clapback/models
56
+ ```
57
+ Without them, lookups still work; embedding does not, and the plugin says so.
58
+
59
+ ## What leaves the machine
60
+
61
+ Nothing until `contribute: yes`. Then, per track the commons did not already hold: a 512-float
62
+ vector and a one-way hash. **Never audio, never paths, never tags.** The hash cannot be reversed
63
+ into the fingerprint, and the fingerprint is not the audio.
64
+
65
+ The first contribution mints a random `client_id` — a UUID, derived from nothing about you or
66
+ your machine — and stores it at `clapback/client_id` under your config directory. It lets the
67
+ commons tell two contributions apart from one client retrying. Delete it and you are a new
68
+ contributor; nothing else changes.
69
+
70
+ ## Options
71
+
72
+ ```yaml
73
+ clapback:
74
+ url: https://clapback.seethroughlab.com
75
+ contribute: no # send vectors the commons lacks
76
+ auto: no # run on every import (embedding is minutes per album; off by default)
77
+ pace: 0.15 # seconds between contributions
78
+ ```
79
+
80
+ ## Why this exists
81
+
82
+ beets shipped `acousticbrainz` and `absubmit` for years. Both were deprecated when AcousticBrainz
83
+ shut down, and the suggested replacement was to compute locally and keep the results. This is the
84
+ other half back — with a difference that matters: AcousticBrainz pooled *estimates* (bpm, key,
85
+ mood) that could never be reconciled between contributors. An embedding from a pinned pipeline
86
+ can be. When two people contribute the same recording, the commons can say whether they agree.
87
+ The reasoning is in
88
+ [`ADR-0011`](https://github.com/seethroughlab/clapback/blob/main/docs/decisions/ADR-0011-the-commons-is-what-other-tools-plug-into.md).
@@ -0,0 +1,346 @@
1
+ """beets-clapback — look up, embed, and (if you say so) contribute.
2
+
3
+ `ADR-0011` point 5's first integration, and `absubmit` reborn with a better
4
+ payload. beets shipped `acousticbrainz` and `absubmit` for years; both were
5
+ deprecated when AcousticBrainz died, and the suggested replacement was to compute
6
+ locally and keep the results. This is the other half back.
7
+
8
+ beet clapback # every track: look up, else embed; contribute if enabled
9
+ beet clapback -p # say what would happen, touch nothing
10
+ beet clapback artist:Autechre
11
+ beet clapback-search "dreamy ambient with piano"
12
+
13
+ What it stores on each item, as flexible attributes you can query and see:
14
+
15
+ clapback_hash the corpus key — SHA256 of the AcoustID fingerprint
16
+ clapback_status found | contributed | local | unfingerprinted | unembedded
17
+
18
+ The embeddings themselves live in a sidecar store under your beets config
19
+ directory, keyed by item id, so `clapback-search` works offline over what has
20
+ been embedded or fetched.
21
+
22
+ What leaves the machine, and only when `contribute: yes`: a 512-float vector and
23
+ a one-way hash. Never audio, never paths, never tags. The hash cannot be reversed
24
+ into the fingerprint, and the fingerprint is not the audio.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import os
31
+ from pathlib import Path
32
+
33
+ from beets import config as beets_config
34
+ from beets import plugins, ui
35
+ from beets.exceptions import UserError
36
+ from clapback_client import (
37
+ Corpus,
38
+ CorpusError,
39
+ FingerprintUnavailable,
40
+ ensure_client_id,
41
+ fingerprint_file,
42
+ hash_fingerprint,
43
+ )
44
+
45
+ #: The flexible attributes this plugin writes. Named so they group in `beet ls`.
46
+ HASH_FIELD = "clapback_hash"
47
+ STATUS_FIELD = "clapback_status"
48
+
49
+
50
+ def _embedder():
51
+ """`clapback-embed`, imported lazily so `beet` starts fast and a missing model
52
+ is reported by the command that needs it rather than at plugin load."""
53
+ import clapback_embed
54
+
55
+ return clapback_embed
56
+
57
+
58
+ class _Store:
59
+ """Vectors by item id, in two files under the beets config directory.
60
+
61
+ The same shape as the reference client's store — a `.npy` of rows and a
62
+ small JSON index — because it is the smallest thing that answers a search:
63
+ a text query is one dot product against every row, and a `.npy` loads into
64
+ exactly the array that needs. Not the beets database, which stores flexible
65
+ attributes as strings and would make `beet ls` print 512 floats.
66
+ """
67
+
68
+ def __init__(self, home: Path) -> None:
69
+ self.home = home
70
+ self.vectors_path = home / "vectors.npy"
71
+ self.index_path = home / "index.json"
72
+ self.ids: list[int] = []
73
+ self.pipeline_version: str | None = None
74
+ self._vectors = None
75
+
76
+ def load(self) -> _Store:
77
+ import numpy as np
78
+
79
+ if self.index_path.exists():
80
+ data = json.loads(self.index_path.read_text())
81
+ self.ids = [int(i) for i in data.get("ids", [])]
82
+ self.pipeline_version = data.get("pipeline_version")
83
+ if self.vectors_path.exists():
84
+ self._vectors = np.load(self.vectors_path)
85
+ else:
86
+ self._vectors = np.zeros((0, 512), dtype=np.float32)
87
+ if len(self.ids) != len(self._vectors):
88
+ # Two halves that disagree attribute every result to the wrong track.
89
+ self.ids, self._vectors = [], np.zeros((0, 512), dtype=np.float32)
90
+ return self
91
+
92
+ def save(self) -> None:
93
+ import numpy as np
94
+
95
+ self.home.mkdir(parents=True, exist_ok=True)
96
+ np.save(self.vectors_path, self._vectors)
97
+ self.index_path.write_text(
98
+ json.dumps({"pipeline_version": self.pipeline_version, "ids": self.ids}, indent=1)
99
+ )
100
+
101
+ def __contains__(self, item_id: int) -> bool:
102
+ return item_id in self.ids
103
+
104
+ def put(self, item_id: int, vector: list[float]) -> None:
105
+ import numpy as np
106
+
107
+ v = np.asarray(vector, dtype=np.float32).reshape(1, -1)
108
+ if item_id in self.ids:
109
+ self._vectors[self.ids.index(item_id)] = v
110
+ return
111
+ self.ids.append(item_id)
112
+ self._vectors = np.vstack([self._vectors, v]) if len(self._vectors) else v
113
+
114
+ def get(self, item_id: int) -> list[float] | None:
115
+ if item_id not in self.ids:
116
+ return None
117
+ return [float(x) for x in self._vectors[self.ids.index(item_id)]]
118
+
119
+ def nearest(self, query, limit: int) -> list[tuple[int, float]]:
120
+ import numpy as np
121
+
122
+ if not len(self._vectors):
123
+ return []
124
+ sims = self._vectors @ np.asarray(query, dtype=np.float32)
125
+ top = np.argsort(-sims)[:limit]
126
+ return [(self.ids[int(i)], float(sims[i])) for i in top]
127
+
128
+
129
+ class ClapbackPlugin(plugins.BeetsPlugin):
130
+ def __init__(self) -> None:
131
+ super().__init__()
132
+ self.config.add(
133
+ {
134
+ "url": "https://clapback.seethroughlab.com",
135
+ # `ADR-0011` point 6: opt-in, and a second setting from lookup.
136
+ # A plugin in somebody else's software has less standing to
137
+ # assume consent, not more.
138
+ "contribute": False,
139
+ # Run on import. Off by default because embedding is minutes per
140
+ # album on a laptop and an import should not silently become that.
141
+ "auto": False,
142
+ "pace": 0.15,
143
+ }
144
+ )
145
+ if self.config["auto"].get(bool):
146
+ self.register_listener("album_imported", self._on_album_imported)
147
+ self.register_listener("item_imported", self._on_item_imported)
148
+
149
+ # --- where things live --------------------------------------------------
150
+
151
+ def _home(self) -> Path:
152
+ return Path(beets_config.config_dir()) / "clapback"
153
+
154
+ def _client_id(self) -> str:
155
+ """Minted on first contribution and never before — `ADR-0009` point 4
156
+ covers the fact that an install exists. Stored as a file the user can
157
+ find and delete; deleting it makes them a new contributor."""
158
+ return ensure_client_id(self._home() / "client_id")
159
+
160
+ # --- the commands -------------------------------------------------------
161
+
162
+ def commands(self) -> list[ui.Subcommand]:
163
+ run = ui.Subcommand(
164
+ "clapback", help="look up or embed tracks; contribute if enabled"
165
+ )
166
+ run.parser.add_option(
167
+ "-p", "--pretend", action="store_true", help="say what would happen, touch nothing"
168
+ )
169
+ run.parser.add_option(
170
+ "-f", "--force", action="store_true", help="re-process tracks already done"
171
+ )
172
+ run.func = self._cmd_run
173
+
174
+ search = ui.Subcommand(
175
+ "clapback-search", help="find tracks matching a description"
176
+ )
177
+ search.parser.add_option("-n", "--limit", type="int", default=10)
178
+ search.func = self._cmd_search
179
+ return [run, search]
180
+
181
+ def _cmd_run(self, lib, opts, args) -> None:
182
+ items = list(lib.items(args))
183
+ if not items:
184
+ raise UserError("no tracks match")
185
+ self._process(lib, items, pretend=opts.pretend, force=opts.force)
186
+
187
+ def _cmd_search(self, lib, opts, args) -> None:
188
+ description = " ".join(args).strip()
189
+ if not description:
190
+ raise UserError("give a description: beet clapback-search \"dreamy ambient\"")
191
+ embed = _embedder()
192
+ store = _Store(self._home()).load()
193
+ if not store.ids:
194
+ raise UserError("nothing embedded yet — run: beet clapback")
195
+ query = embed.embed_text(description)
196
+ for item_id, score in store.nearest(query, opts.limit):
197
+ item = lib.get_item(item_id)
198
+ if item is not None:
199
+ ui.print_(f"{score:.4f} {item}")
200
+
201
+ # --- import hooks -------------------------------------------------------
202
+
203
+ def _on_album_imported(self, lib, album) -> None:
204
+ self._process(lib, list(album.items()), pretend=False, force=False)
205
+
206
+ def _on_item_imported(self, lib, item) -> None:
207
+ self._process(lib, [item], pretend=False, force=False)
208
+
209
+ # --- the work -----------------------------------------------------------
210
+
211
+ def _process(self, lib, items, *, pretend: bool, force: bool) -> None:
212
+ """Look up, else embed; contribute if enabled. Over these items."""
213
+ import time
214
+
215
+ contribute = self.config["contribute"].get(bool)
216
+ pace = self.config["pace"].as_number()
217
+ corpus = Corpus(self.config["url"].as_str())
218
+
219
+ embed = _embedder()
220
+ pipeline_version = embed.PIPELINE_VERSION
221
+ store = _Store(self._home()).load()
222
+ if store.pipeline_version and store.pipeline_version != pipeline_version:
223
+ # A store from a different pipeline cannot be searched alongside
224
+ # this one, and its vectors cannot be contributed under this
225
+ # identity (`ADR-0006` point 5: recomputed, not relabelled).
226
+ raise UserError(
227
+ f"the local store was built by {store.pipeline_version}\n"
228
+ f"and the installed embedder is {pipeline_version}.\n"
229
+ "Delete the store to re-embed under the new pipeline:\n"
230
+ f" {self._home()}"
231
+ )
232
+ store.pipeline_version = pipeline_version
233
+
234
+ client_id = None
235
+ found = contributed = local = unfingerprinted = unembedded = skipped = 0
236
+ try:
237
+ for n, item in enumerate(items, start=1):
238
+ if not force and item.get(STATUS_FIELD) in ("found", "contributed") and item.id in store:
239
+ skipped += 1
240
+ continue
241
+
242
+ # 1. The key. beets' `chroma` plugin stores the fingerprint as
243
+ # chromaprint returned it — measured 2026-09-13 on 99 tracks,
244
+ # 99 byte-identical — but it goes through `hash_fingerprint`
245
+ # regardless, because `ADR-0010`'s defect came from a column.
246
+ fp = item.get("acoustid_fingerprint")
247
+ if not fp:
248
+ try:
249
+ fp = fingerprint_file(os.fsdecode(item.path))
250
+ except FingerprintUnavailable as exc:
251
+ self._log.info("{0}: no fingerprint ({1})", item, exc)
252
+ unfingerprinted += 1
253
+ if not pretend:
254
+ item[STATUS_FIELD] = "unfingerprinted"
255
+ item.store()
256
+ continue
257
+ key = hash_fingerprint(fp)
258
+
259
+ if pretend:
260
+ ui.print_(f"would process: {item}")
261
+ continue
262
+
263
+ # 2. Look up before anything else. A hit means the commons has
264
+ # done the work; it also means we must not POST, because a
265
+ # repeat submission is recorded as agreement (`ADR-0008`).
266
+ try:
267
+ row = corpus.lookup(key, pipeline_version)
268
+ except CorpusError as exc:
269
+ self._log.warning("corpus unreachable: {0}", exc)
270
+ row = None
271
+ corpus_ok = False
272
+ else:
273
+ corpus_ok = True
274
+
275
+ if row is not None:
276
+ store.put(item.id, row["embedding"])
277
+ item[HASH_FIELD] = key
278
+ item[STATUS_FIELD] = "found"
279
+ item.store()
280
+ found += 1
281
+ continue
282
+
283
+ # 3. Embed locally — unless a previous run already did, in which
284
+ # case the vector is in the store and the model does not run.
285
+ # This is the path a user takes when they turn `contribute`
286
+ # on after indexing: everything they already embedded goes
287
+ # out without being recomputed.
288
+ vector = store.get(item.id)
289
+ if vector is None:
290
+ try:
291
+ vector = [float(x) for x in embed.embed_file(os.fsdecode(item.path))]
292
+ except embed.ArtifactsMissing:
293
+ raise UserError(
294
+ "the ONNX encoders are missing — they are 614 MB and not bundled. "
295
+ "Export them once with clapback-embed's scripts/export_models.py, "
296
+ "or set CLAPBACK_MODEL_DIR to where they already are."
297
+ ) from None
298
+ except Exception as exc: # noqa: BLE001 - one bad file must not end the run
299
+ self._log.info("{0}: could not embed ({1})", item, exc)
300
+ unembedded += 1
301
+ item[STATUS_FIELD] = "unembedded"
302
+ item.store()
303
+ continue
304
+ store.put(item.id, vector)
305
+ item[HASH_FIELD] = key
306
+
307
+ # 4. Contribute, if and only if asked.
308
+ if contribute and corpus_ok:
309
+ if client_id is None:
310
+ client_id = self._client_id()
311
+ try:
312
+ corpus.contribute(
313
+ fingerprint_hash=key,
314
+ embedding=vector,
315
+ pipeline_version=pipeline_version,
316
+ client_id=client_id,
317
+ )
318
+ except CorpusError as exc:
319
+ self._log.warning("{0}: not contributed ({1})", item, exc)
320
+ item[STATUS_FIELD] = "local"
321
+ local += 1
322
+ else:
323
+ item[STATUS_FIELD] = "contributed"
324
+ contributed += 1
325
+ time.sleep(pace)
326
+ else:
327
+ item[STATUS_FIELD] = "local"
328
+ local += 1
329
+ item.store()
330
+
331
+ if n % 25 == 0:
332
+ store.save()
333
+ self._log.info("{0}/{1}", n, len(items))
334
+ finally:
335
+ if not pretend:
336
+ store.save()
337
+
338
+ if pretend:
339
+ return
340
+ summary = (
341
+ f"found in corpus {found} · contributed {contributed} · local only {local} · "
342
+ f"no fingerprint {unfingerprinted} · unembeddable {unembedded} · already done {skipped}"
343
+ )
344
+ if not contribute and local:
345
+ summary += "\n(contribution is off; set clapback.contribute: yes to send local vectors)"
346
+ ui.print_(summary)
@@ -0,0 +1,56 @@
1
+ [project]
2
+ name = "beets-clapback"
3
+ version = "0.1.0"
4
+ description = "beets plugin: look up, embed, and contribute CLAP embeddings to the clapback commons — absubmit reborn"
5
+ readme = "README.md"
6
+ license = { file = "LICENSE" }
7
+ requires-python = ">=3.11"
8
+ authors = [{ name = "Jeff Crouse", email = "jeff@seethroughlab.com" }]
9
+ keywords = ["beets", "clap", "audio", "embeddings", "music", "acoustid"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: End Users/Desktop",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Topic :: Multimedia :: Sound/Audio :: Analysis",
18
+ ]
19
+
20
+ # `clapback-client` is the contract; `clapback-embed` is the reference pipeline
21
+ # this plugin embeds through, and where `PIPELINE_VERSION` comes from. beets users
22
+ # have a library to embed, not their own embedder, so unlike the client the
23
+ # embedder is not optional here — only its 614 MB of encoders are, at runtime.
24
+ dependencies = [
25
+ "beets>=2.0",
26
+ "clapback-client>=0.1.0,<0.2",
27
+ "clapback-embed>=0.1.0,<0.2",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ dev = ["pytest>=7.4.0", "ruff>=0.1.0", "pyacoustid>=1.3"]
32
+
33
+ [project.urls]
34
+ Homepage = "https://clapback.seethroughlab.com"
35
+ Repository = "https://github.com/seethroughlab/clapback"
36
+ Decisions = "https://github.com/seethroughlab/clapback/tree/main/docs/decisions"
37
+
38
+ [build-system]
39
+ requires = ["hatchling"]
40
+ build-backend = "hatchling.build"
41
+
42
+ # `beetsplug` is a PEP 420 namespace package shared by every beets plugin, so
43
+ # this wheel ships `beetsplug/clapback.py` and no `__init__.py`.
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["beetsplug"]
46
+
47
+ [tool.uv.sources]
48
+ clapback-client = { workspace = true }
49
+ clapback-embed = { workspace = true }
50
+
51
+ [tool.ruff]
52
+ line-length = 100
53
+ target-version = "py311"
54
+
55
+ [tool.pytest.ini_options]
56
+ testpaths = ["tests"]
@@ -0,0 +1,236 @@
1
+ """The plugin, driven through beets itself.
2
+
3
+ `beets.test.helper.PluginTestHelper` loads `beetsplug.clapback` the way `beet`
4
+ does and runs commands through `beets.ui`, so what is under test is the plugin
5
+ as a beets user meets it — config keys, flexible attributes, the sidecar store,
6
+ the summary line. Only two things are stubbed: the corpus (a scripted server
7
+ that records what it was asked) and the embedder (which would otherwise need
8
+ 614 MB of ONNX encoders). Neither is the property under test.
9
+
10
+ The properties are the ones `ADR-0011` point 5 and point 6 ask of any plug-in:
11
+ look up before you contribute, contribute only when told to, never re-embed what
12
+ is already embedded, hash canonically, and mint nothing for a user who has not
13
+ contributed.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+
20
+ import pytest
21
+ from beets.exceptions import UserError
22
+ from beets.test.helper import PluginTestHelper
23
+
24
+ import beetsplug.clapback as plug
25
+
26
+ PIPELINE = "laion/clap-htsat-unfused+frontend1+artifact1+pool1+fp32"
27
+ RAW_FP = "AQADtJESbVkUhYL84z4CnwZ4HsdxHD6P4_hx_EAO_cjx"
28
+
29
+
30
+ def _unit(i: int) -> list[float]:
31
+ v = [0.0] * 512
32
+ v[i % 512] = 1.0
33
+ return v
34
+
35
+
36
+ class FakeEmbed:
37
+ PIPELINE_VERSION = PIPELINE
38
+
39
+ class ArtifactsMissing(Exception):
40
+ pass
41
+
42
+ def __init__(self):
43
+ self.calls: list[str] = []
44
+
45
+ def embed_file(self, path):
46
+ self.calls.append(path)
47
+ return _unit(len(self.calls))
48
+
49
+ def embed_text(self, text):
50
+ # Point the query at the first embedded track.
51
+ return _unit(1)
52
+
53
+
54
+ class FakeCorpus:
55
+ base_url = "https://example.invalid"
56
+
57
+ def __init__(self, *_a, **_k):
58
+ pass
59
+
60
+ # Class-level so the plugin's fresh instances share one script; the
61
+ # fixture resets them before every test.
62
+ holds: dict[tuple[str, str], list[float]] = {} # noqa: RUF012
63
+ log: list[tuple] = [] # noqa: RUF012
64
+ unreachable = False
65
+
66
+ def lookup(self, h, pv):
67
+ FakeCorpus.log.append(("lookup", h, pv))
68
+ if FakeCorpus.unreachable:
69
+ raise plug.CorpusError("down")
70
+ return {"embedding": FakeCorpus.holds[(h, pv)]} if (h, pv) in FakeCorpus.holds else None
71
+
72
+ def contribute(self, **kw):
73
+ FakeCorpus.log.append(("contribute", kw["fingerprint_hash"], kw["pipeline_version"]))
74
+ FakeCorpus.last_kw = kw
75
+ return "contributed"
76
+
77
+
78
+ class TestClapbackPlugin(PluginTestHelper):
79
+ plugin = "clapback"
80
+ preload_plugin = False
81
+
82
+ @pytest.fixture(autouse=True)
83
+ def _stubs(self, monkeypatch, setup):
84
+ # `setup` is the helper's own autouse fixture; naming it orders this
85
+ # one after beets is up and before it is torn down.
86
+ self.embed = FakeEmbed()
87
+ FakeCorpus.holds, FakeCorpus.log, FakeCorpus.unreachable = {}, [], False
88
+ monkeypatch.setattr(plug, "_embedder", lambda: self.embed)
89
+ monkeypatch.setattr(plug, "Corpus", FakeCorpus)
90
+
91
+ # --- helpers --------------------------------------------------------
92
+
93
+ def _track(self, title="t", fp=RAW_FP, **kw):
94
+ return self.add_item(title=title, acoustid_fingerprint=fp, **kw)
95
+
96
+ def _run(self, *args, **cfg):
97
+ with self.configure_plugin({"contribute": False, **cfg}):
98
+ self.run_command("clapback", *args)
99
+
100
+ def _kinds(self):
101
+ return [k for k, *_ in FakeCorpus.log]
102
+
103
+ def _store(self):
104
+ return json.loads((plug.Path(self.config.config_dir()) / "clapback" / "index.json").read_text())
105
+
106
+ # --- the four obligations ------------------------------------------
107
+
108
+ def test_a_hit_in_the_corpus_means_the_model_does_not_run(self):
109
+ """The whole exchange a plug-in makes."""
110
+ t = self._track()
111
+ FakeCorpus.holds[(plug.hash_fingerprint(RAW_FP), PIPELINE)] = _unit(7)
112
+ self._run()
113
+ t.load()
114
+ assert t.clapback_status == "found"
115
+ assert self.embed.calls == []
116
+ assert self._kinds() == ["lookup"]
117
+
118
+ def test_a_miss_is_embedded_and_kept_local_unless_asked(self, capsys):
119
+ """`ADR-0011` point 6: contribution is a second, separate setting."""
120
+ t = self._track()
121
+ self._run()
122
+ t.load()
123
+ assert t.clapback_status == "local"
124
+ assert len(self.embed.calls) == 1
125
+ assert "contribute" not in self._kinds()
126
+ assert "contribution is off" in capsys.readouterr().out
127
+
128
+ def test_contributing_sends_what_the_records_require(self):
129
+ t = self._track()
130
+ self._run(contribute=True)
131
+ t.load()
132
+ assert t.clapback_status == "contributed"
133
+ assert self._kinds() == ["lookup", "contribute"], "look up first, always"
134
+ kw = FakeCorpus.last_kw
135
+ assert kw["pipeline_version"] == PIPELINE
136
+ assert kw["client_id"]
137
+ assert len(kw["embedding"]) == 512
138
+
139
+ def test_the_hash_is_canonical_whatever_the_column_holds(self):
140
+ """`ADR-0010` point 2. beets' column measured clean on 99 tracks, but a
141
+ column is where the last defect hid, so the rule applies regardless."""
142
+ raw = self._track("raw", fp=RAW_FP)
143
+ esc = self._track("esc", fp="\\x" + RAW_FP.encode().hex())
144
+ self._run()
145
+ raw.load()
146
+ esc.load()
147
+ assert raw.clapback_hash == esc.clapback_hash
148
+
149
+ # --- restraint -------------------------------------------------------
150
+
151
+ def test_turning_contribution_on_later_does_not_re_embed(self):
152
+ """The vector is in the store; the model does not run again. This is
153
+ the path a user takes after indexing with contribution off."""
154
+ t = self._track()
155
+ self._run()
156
+ assert len(self.embed.calls) == 1
157
+ FakeCorpus.log.clear()
158
+ self._run(contribute=True)
159
+ t.load()
160
+ assert len(self.embed.calls) == 1, "re-embedded a track it already had"
161
+ assert t.clapback_status == "contributed"
162
+
163
+ def test_done_tracks_are_skipped_without_force(self):
164
+ self._track()
165
+ self._run(contribute=True)
166
+ FakeCorpus.log.clear()
167
+ self._run(contribute=True)
168
+ assert FakeCorpus.log == [], "a finished track went back to the corpus"
169
+ self._run("-f", contribute=True)
170
+ assert "lookup" in self._kinds()
171
+
172
+ def test_pretend_touches_nothing(self, capsys):
173
+ t = self._track()
174
+ self._run("-p", contribute=True)
175
+ t.load()
176
+ assert t.get("clapback_status") is None
177
+ assert FakeCorpus.log == [] and self.embed.calls == []
178
+ assert "would process" in capsys.readouterr().out
179
+
180
+ def test_no_identifier_is_minted_until_a_contribution_happens(self):
181
+ """`ADR-0009` point 4 covers the fact that an install exists."""
182
+ self._track()
183
+ self._run()
184
+ assert not (plug.Path(self.config.config_dir()) / "clapback" / "client_id").exists()
185
+ self._run(contribute=True)
186
+ assert (plug.Path(self.config.config_dir()) / "clapback" / "client_id").exists()
187
+
188
+ # --- when things are missing ----------------------------------------
189
+
190
+ def test_no_fingerprint_and_no_chromaprint_is_a_status_not_a_crash(self, monkeypatch):
191
+ monkeypatch.setattr(
192
+ plug, "fingerprint_file", lambda p: (_ for _ in ()).throw(
193
+ plug.FingerprintUnavailable("chromaprint is not installed")
194
+ )
195
+ )
196
+ t = self.add_item(title="nofp")
197
+ self._run()
198
+ t.load()
199
+ assert t.clapback_status == "unfingerprinted"
200
+ assert self.embed.calls == []
201
+
202
+ def test_an_unreachable_corpus_still_embeds_locally(self):
203
+ """Offline is a normal state. The store fills; nothing is sent."""
204
+ FakeCorpus.unreachable = True
205
+ t = self._track()
206
+ self._run(contribute=True)
207
+ t.load()
208
+ assert t.clapback_status == "local"
209
+ assert len(self.embed.calls) == 1
210
+ assert "contribute" not in self._kinds()
211
+
212
+ def test_a_store_from_another_pipeline_is_refused(self):
213
+ """`ADR-0006` point 5: recomputed, not relabelled."""
214
+ self._track()
215
+ self._run()
216
+ idx = plug.Path(self.config.config_dir()) / "clapback" / "index.json"
217
+ data = json.loads(idx.read_text())
218
+ data["pipeline_version"] = "something+else"
219
+ idx.write_text(json.dumps(data))
220
+ with pytest.raises(UserError, match="Delete the store"):
221
+ self._run("-f")
222
+
223
+ # --- the local value ------------------------------------------------
224
+
225
+ def test_search_ranks_the_matching_track_first(self, capsys):
226
+ """`ADR-0001` point 8: worth running with the corpus empty."""
227
+ a = self._track("alpha", fp=RAW_FP)
228
+ b = self._track("beta", fp=RAW_FP[::-1])
229
+ self._run()
230
+ capsys.readouterr() # drop the run's summary line
231
+ with self.configure_plugin({}):
232
+ self.run_command("clapback-search", "anything at all")
233
+ out = capsys.readouterr().out.strip().splitlines()
234
+ assert "alpha" in out[0]
235
+ assert self._store()["pipeline_version"] == PIPELINE
236
+ assert sorted(self._store()["ids"]) == sorted([a.id, b.id])