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])
|