beets-clapback 0.1.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.
|
@@ -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,5 @@
|
|
|
1
|
+
beetsplug/clapback.py,sha256=LKCouRJ7XhMYQILSavlho3WOyY4IcW-sqgU_8H4vPX0,14517
|
|
2
|
+
beets_clapback-0.1.0.dist-info/METADATA,sha256=TiwanZzFZ22HtwqOSzQ1zJMJx9QnLRS5h0IW76V1icw,6230
|
|
3
|
+
beets_clapback-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
4
|
+
beets_clapback-0.1.0.dist-info/licenses/LICENSE,sha256=6Tfw3KwvxvDfednEoMB-s180r3b0xff9qWj57iXpiGs,1068
|
|
5
|
+
beets_clapback-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|
beetsplug/clapback.py
ADDED
|
@@ -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)
|