diglibrary 1.1.1__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.
Potentially problematic release.
This version of diglibrary might be problematic. Click here for more details.
- diglibrary/__init__.py +5 -0
- diglibrary/__main__.py +107 -0
- diglibrary/application/__init__.py +1 -0
- diglibrary/application/acoustic.py +289 -0
- diglibrary/application/api.py +10060 -0
- diglibrary/application/artwork.py +542 -0
- diglibrary/application/bench.py +559 -0
- diglibrary/application/composition.py +625 -0
- diglibrary/application/contracts.py +224 -0
- diglibrary/application/identification.py +1636 -0
- diglibrary/application/quality.py +421 -0
- diglibrary/cachedir.py +141 -0
- diglibrary/config/__init__.py +6 -0
- diglibrary/config/credentials.py +118 -0
- diglibrary/config/default.toml +113 -0
- diglibrary/config/home.py +81 -0
- diglibrary/config/loader.py +319 -0
- diglibrary/config/models.py +111 -0
- diglibrary/connectors/__init__.py +36 -0
- diglibrary/connectors/contracts.py +155 -0
- diglibrary/connectors/models.py +471 -0
- diglibrary/connectors/slskd/__init__.py +21 -0
- diglibrary/connectors/slskd/client.py +584 -0
- diglibrary/connectors/slskd/configuration.py +76 -0
- diglibrary/connectors/slskd/connector.py +307 -0
- diglibrary/connectors/slskd/exceptions.py +239 -0
- diglibrary/connectors/slskd/mapper.py +343 -0
- diglibrary/connectors/slskd/models.py +46 -0
- diglibrary/database/__init__.py +6 -0
- diglibrary/database/connection.py +232 -0
- diglibrary/database/copies.py +324 -0
- diglibrary/database/library_store.py +2790 -0
- diglibrary/database/migrations.py +1133 -0
- diglibrary/database/repositories.py +41 -0
- diglibrary/database/serialization.py +163 -0
- diglibrary/desktop/__init__.py +15 -0
- diglibrary/desktop/bundle.py +161 -0
- diglibrary/desktop/icon.py +215 -0
- diglibrary/desktop/restore.py +83 -0
- diglibrary/engines/__init__.py +21 -0
- diglibrary/engines/contracts.py +61 -0
- diglibrary/harmonic/__init__.py +0 -0
- diglibrary/harmonic/analyzer.py +154 -0
- diglibrary/harmonic/keys.py +161 -0
- diglibrary/harmonic/mixing.py +100 -0
- diglibrary/harmonic/tempo.py +132 -0
- diglibrary/identity.py +17 -0
- diglibrary/library/__init__.py +50 -0
- diglibrary/library/artwork.py +653 -0
- diglibrary/library/atomicwrite.py +120 -0
- diglibrary/library/audio.py +84 -0
- diglibrary/library/audio_key.py +212 -0
- diglibrary/library/containers.py +191 -0
- diglibrary/library/executor.py +810 -0
- diglibrary/library/extras.py +184 -0
- diglibrary/library/fingerprint.py +147 -0
- diglibrary/library/hints.py +630 -0
- diglibrary/library/integrity.py +246 -0
- diglibrary/library/matching.py +1918 -0
- diglibrary/library/medleys.py +211 -0
- diglibrary/library/models.py +90 -0
- diglibrary/library/naming.py +1140 -0
- diglibrary/library/planner.py +1113 -0
- diglibrary/library/report.py +309 -0
- diglibrary/library/scanner.py +416 -0
- diglibrary/library/series.py +255 -0
- diglibrary/library/signature.py +56 -0
- diglibrary/library/spelling.py +202 -0
- diglibrary/library/tagrelease.py +329 -0
- diglibrary/library/tags.py +320 -0
- diglibrary/library/xattrs.py +194 -0
- diglibrary/logging/__init__.py +5 -0
- diglibrary/logging/setup.py +132 -0
- diglibrary/metadata/__init__.py +27 -0
- diglibrary/metadata/acoustid.py +148 -0
- diglibrary/metadata/authentication.py +54 -0
- diglibrary/metadata/cache.py +111 -0
- diglibrary/metadata/coverart.py +207 -0
- diglibrary/metadata/discogs.py +309 -0
- diglibrary/metadata/itunes.py +314 -0
- diglibrary/metadata/matching.py +24 -0
- diglibrary/metadata/models.py +21 -0
- diglibrary/metadata/musicbrainz.py +304 -0
- diglibrary/metadata/normalization.py +105 -0
- diglibrary/metadata/rate_limit.py +32 -0
- diglibrary/metadata/retry.py +69 -0
- diglibrary/metadata/service.py +187 -0
- diglibrary/metadata/spotify.py +284 -0
- diglibrary/metadata/transport.py +181 -0
- diglibrary/providers/__init__.py +5 -0
- diglibrary/providers/capabilities.py +25 -0
- diglibrary/providers/contracts.py +55 -0
- diglibrary/providers/manager.py +229 -0
- diglibrary/providers/models.py +162 -0
- diglibrary/providers/registry.py +65 -0
- diglibrary/providers/soulseek.py +394 -0
- diglibrary/providers/types.py +30 -0
- diglibrary/quality/__init__.py +41 -0
- diglibrary/quality/analysis.py +399 -0
- diglibrary/quality/confidence.py +199 -0
- diglibrary/quality/models.py +391 -0
- diglibrary/quality/spectrogram.py +276 -0
- diglibrary/quality/verdict.py +578 -0
- diglibrary/ui/__init__.py +1 -0
- diglibrary/ui/app.py +245 -0
- diglibrary/ui/audio.py +314 -0
- diglibrary/ui/drops.py +95 -0
- diglibrary/ui/folders.py +108 -0
- diglibrary/ui/wakefulness.py +83 -0
- diglibrary/ui/web/app.js +10845 -0
- diglibrary/ui/web/index.html +1183 -0
- diglibrary/ui/web/strings.js +1805 -0
- diglibrary/ui/web/styles.css +3829 -0
- diglibrary-1.1.1.dist-info/METADATA +452 -0
- diglibrary-1.1.1.dist-info/RECORD +119 -0
- diglibrary-1.1.1.dist-info/WHEEL +5 -0
- diglibrary-1.1.1.dist-info/entry_points.txt +2 -0
- diglibrary-1.1.1.dist-info/licenses/LICENSE +21 -0
- diglibrary-1.1.1.dist-info/top_level.txt +1 -0
diglibrary/__init__.py
ADDED
diglibrary/__main__.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""``diglibrary`` opens the window, and its two other words do the two jobs a
|
|
2
|
+
checkout used to have scripts for."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
# The words that mean something other than *open the window*. Anything else,
|
|
9
|
+
# including nothing at all and including every option the window takes, goes to
|
|
10
|
+
# the window exactly as it did before there were any words here — a default that
|
|
11
|
+
# is usually right must not be what refuses an explicit request (ADR-109), and
|
|
12
|
+
# the reverse holds too: a word nobody typed must not change what happens.
|
|
13
|
+
COMMANDS = ("make-icon", "restore-copy")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> None:
|
|
17
|
+
"""Parse the command line and hand over to the window, or to a command."""
|
|
18
|
+
if len(sys.argv) > 1 and sys.argv[1] in COMMANDS:
|
|
19
|
+
raise SystemExit(_command(sys.argv[1], sys.argv[2:]))
|
|
20
|
+
|
|
21
|
+
parser = argparse.ArgumentParser(
|
|
22
|
+
prog="diglibrary",
|
|
23
|
+
description="Open the DigLibrary window.",
|
|
24
|
+
epilog=(
|
|
25
|
+
"Other things it can do: "
|
|
26
|
+
"`diglibrary make-icon` puts an icon in your Dock so you never need "
|
|
27
|
+
"this terminal again; "
|
|
28
|
+
"`diglibrary restore-copy` lists the copies of your database and "
|
|
29
|
+
"puts one back."
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--config",
|
|
34
|
+
type=Path,
|
|
35
|
+
default=None,
|
|
36
|
+
help=(
|
|
37
|
+
"Path to the configuration file. Without one: ./config.toml when it "
|
|
38
|
+
"is there, otherwise ~/.diglibrary/config.toml, written on first run."
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--debug", action="store_true", help="Open the window with developer tools enabled."
|
|
43
|
+
)
|
|
44
|
+
arguments = parser.parse_args()
|
|
45
|
+
|
|
46
|
+
from diglibrary.ui.app import run
|
|
47
|
+
|
|
48
|
+
# Resolved inside `run` rather than here, because choosing the file can fail
|
|
49
|
+
# the same way reading it can — a directory holding a database and no
|
|
50
|
+
# settings is refused — and there is one place that turns a configuration
|
|
51
|
+
# failure into a window someone can read.
|
|
52
|
+
run(arguments.config, debug=arguments.debug)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _command(name: str, arguments: list[str]) -> int:
|
|
56
|
+
"""Run one of the words above, and say what happened in words a person reads."""
|
|
57
|
+
if name == "make-icon":
|
|
58
|
+
return _make_icon(arguments)
|
|
59
|
+
return _restore_copy(arguments)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _make_icon(arguments: list[str]) -> int:
|
|
63
|
+
"""Assemble the Dock icon for whichever installation is running."""
|
|
64
|
+
parser = argparse.ArgumentParser(
|
|
65
|
+
prog="diglibrary make-icon",
|
|
66
|
+
description=(
|
|
67
|
+
"Put DigLibrary in your Dock. The icon launches the copy of "
|
|
68
|
+
"DigLibrary you are running this from, on this Mac only — it is not "
|
|
69
|
+
"an application you can send to anybody."
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"destination",
|
|
74
|
+
nargs="?",
|
|
75
|
+
type=Path,
|
|
76
|
+
default=None,
|
|
77
|
+
help="Where to put it. Default: ~/Applications",
|
|
78
|
+
)
|
|
79
|
+
chosen = parser.parse_args(arguments)
|
|
80
|
+
|
|
81
|
+
if sys.platform != "darwin":
|
|
82
|
+
print("A Dock is a macOS thing, and this is not macOS. Nothing was written.")
|
|
83
|
+
return 1
|
|
84
|
+
|
|
85
|
+
from diglibrary.desktop.bundle import make_icon
|
|
86
|
+
|
|
87
|
+
print("Drawing the icon at every size it needs. About nine seconds.")
|
|
88
|
+
try:
|
|
89
|
+
bundle = make_icon(chosen.destination)
|
|
90
|
+
except FileNotFoundError:
|
|
91
|
+
# `iconutil` ships with macOS and with the developer tools, and saying
|
|
92
|
+
# which command was missing beats a traceback about a path nobody typed.
|
|
93
|
+
print("refused: macOS's own `iconutil` is not on the path. Nothing was written.")
|
|
94
|
+
return 1
|
|
95
|
+
print(f"Done. {bundle} — open it once from Finder, then keep it in your Dock.")
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _restore_copy(arguments: list[str]) -> int:
|
|
100
|
+
"""List the copies of the database, or put one back."""
|
|
101
|
+
from diglibrary.desktop.restore import restore
|
|
102
|
+
|
|
103
|
+
return restore(arguments)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Application composition and cross-boundary contracts."""
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Identifying an album by what its audio is, when its names say nothing usable."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
from diglibrary.application.contracts import MetadataSourceId, MetadataSources, ReleaseMetadata
|
|
11
|
+
from diglibrary.library.audio_key import audio_key
|
|
12
|
+
from diglibrary.library.fingerprint import AudioFingerprint
|
|
13
|
+
from diglibrary.library.models import AlbumUnit
|
|
14
|
+
from diglibrary.metadata.acoustid import AcousticMatch, AcoustIdClient
|
|
15
|
+
from diglibrary.metadata.service import MetadataService
|
|
16
|
+
|
|
17
|
+
DEFAULT_SAMPLE_SIZE = 3
|
|
18
|
+
"""How many tracks of an album are fingerprinted before it is asked about.
|
|
19
|
+
|
|
20
|
+
Measured on the owner's library (ADR-125): one track names nine to nineteen
|
|
21
|
+
release groups, and three tracks agreeing narrow that to one or two. Fewer than
|
|
22
|
+
three leaves the intersection wide; more spends lookups at three a second to
|
|
23
|
+
confirm what three already said.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
DEFAULT_GROUP_LIMIT = 2
|
|
27
|
+
"""How many release groups are browsed for pressings. Each one costs a request."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Fingerprinter(Protocol):
|
|
31
|
+
"""Produce one file's acoustic fingerprint, or nothing when it cannot."""
|
|
32
|
+
|
|
33
|
+
def fingerprint(self, path: object) -> AudioFingerprint | None:
|
|
34
|
+
"""Return the fingerprint of the audio at ``path``."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class FingerprintStore(Protocol):
|
|
38
|
+
"""Remember a fingerprint against the audio it was computed from.
|
|
39
|
+
|
|
40
|
+
Keyed by content signature rather than by path (ADR-125), which is what
|
|
41
|
+
makes it survive the rename that organizing performs — and by the audio's
|
|
42
|
+
own key as well (migration 19), because a signature is a declared shape and
|
|
43
|
+
two different recordings can wear one.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def fingerprint_for(
|
|
47
|
+
self, content_signature: str, audio_key: str | None = None
|
|
48
|
+
) -> AudioFingerprint | None:
|
|
49
|
+
"""Return a stored fingerprint, or ``None``."""
|
|
50
|
+
|
|
51
|
+
def remember_fingerprint(
|
|
52
|
+
self, content_signature: str, audio_key: str | None, value: AudioFingerprint
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Store a fingerprint against the audio it belongs to."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class _OneFile:
|
|
59
|
+
"""One file put to the service by itself, in the shape the helpers read.
|
|
60
|
+
|
|
61
|
+
`_fingerprint` and `_remember_answer` were written against the scanner's
|
|
62
|
+
`AudioFileFacts` and need exactly two of its fields. Naming that here beats
|
|
63
|
+
passing the whole scanned unit for one file.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
path: Path
|
|
67
|
+
content_signature: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AcousticIdentification:
|
|
71
|
+
"""Purpose: turn an album's audio into the releases it might be.
|
|
72
|
+
|
|
73
|
+
Responsibilities: fingerprint a sample of an album's tracks, ask AcoustID
|
|
74
|
+
what they are, find the release group its tracks agree on, and return that
|
|
75
|
+
group's pressings. Boundaries: it decides nothing and writes no metadata —
|
|
76
|
+
what comes back are candidates, scored and chosen exactly like any other
|
|
77
|
+
(the owner's ruling on 2026-07-30). Dependencies: an injected fingerprinter,
|
|
78
|
+
AcoustID client, and metadata service. Collaborators: the identification
|
|
79
|
+
workflow, which asks only when an album did not settle. Constraints: every
|
|
80
|
+
failure is an empty answer, because acoustic identification never blocks
|
|
81
|
+
organizing (ADR-016).
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
fingerprinter: Fingerprinter,
|
|
87
|
+
acoustid: AcoustIdClient,
|
|
88
|
+
metadata: MetadataService,
|
|
89
|
+
logger: logging.Logger,
|
|
90
|
+
store: FingerprintStore | None = None,
|
|
91
|
+
sample_size: int = DEFAULT_SAMPLE_SIZE,
|
|
92
|
+
group_limit: int = DEFAULT_GROUP_LIMIT,
|
|
93
|
+
source: MetadataSourceId = MetadataSources.MUSICBRAINZ,
|
|
94
|
+
audio_key_of: Callable[[Path], str | None] = audio_key,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Create the acoustic path over already-assembled infrastructure."""
|
|
97
|
+
self._fingerprinter = fingerprinter
|
|
98
|
+
self._acoustid = acoustid
|
|
99
|
+
self._metadata = metadata
|
|
100
|
+
self._logger = logger
|
|
101
|
+
self._store = store
|
|
102
|
+
self._sample_size = sample_size
|
|
103
|
+
self._group_limit = group_limit
|
|
104
|
+
self._source = source
|
|
105
|
+
self._audio_key_of = audio_key_of
|
|
106
|
+
self._keys: dict[object, str | None] = {}
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def is_available(self) -> bool:
|
|
110
|
+
"""Return whether the audio can be asked at all, so the window can say why not."""
|
|
111
|
+
return self._acoustid.is_configured
|
|
112
|
+
|
|
113
|
+
def releases_for(self, unit: AlbumUnit) -> tuple[ReleaseMetadata, ...]:
|
|
114
|
+
"""Return the pressings this album's own audio points at."""
|
|
115
|
+
if not self._acoustid.is_configured or not unit.audio_files:
|
|
116
|
+
return ()
|
|
117
|
+
groups = self._groups_named_by(unit)
|
|
118
|
+
if not groups:
|
|
119
|
+
return ()
|
|
120
|
+
releases: list[ReleaseMetadata] = []
|
|
121
|
+
for group_id in groups:
|
|
122
|
+
releases.extend(self._metadata.releases_in_group(self._source, group_id))
|
|
123
|
+
self._logger.info(
|
|
124
|
+
"The audio named an album.",
|
|
125
|
+
extra={
|
|
126
|
+
"operation": "acoustic.identified",
|
|
127
|
+
"groups": len(groups),
|
|
128
|
+
"releases": len(releases),
|
|
129
|
+
},
|
|
130
|
+
)
|
|
131
|
+
return tuple(releases)
|
|
132
|
+
|
|
133
|
+
def _groups_named_by(self, unit: AlbumUnit) -> tuple[str, ...]:
|
|
134
|
+
"""Return the release groups this album's sampled tracks agree on.
|
|
135
|
+
|
|
136
|
+
Agreement is the whole instrument. One track of the owner's `Construção`
|
|
137
|
+
named nine release groups and one named two; the groups all three
|
|
138
|
+
sampled tracks shared were two. A group named by more of the sample is a
|
|
139
|
+
better answer than one named by less, and only the best are browsed
|
|
140
|
+
because each browse is a request.
|
|
141
|
+
"""
|
|
142
|
+
named: Counter[str] = Counter()
|
|
143
|
+
answered = 0
|
|
144
|
+
for file in self._sample(unit.audio_files):
|
|
145
|
+
fingerprint = self._fingerprint(file)
|
|
146
|
+
if fingerprint is None:
|
|
147
|
+
continue
|
|
148
|
+
matches = self._acoustid.lookup(fingerprint)
|
|
149
|
+
# Written down whichever way it went. Everything this service says
|
|
150
|
+
# used to be consumed and dropped, so nothing could ever be shown
|
|
151
|
+
# about it and the engine read as a rumour: measured on his library
|
|
152
|
+
# on 2026-08-01, 29 fingerprints computed and not one row saying
|
|
153
|
+
# what any of them was told (ADR-155).
|
|
154
|
+
self._remember_answer(file, matches[0] if matches else None)
|
|
155
|
+
if not matches:
|
|
156
|
+
continue
|
|
157
|
+
answered += 1
|
|
158
|
+
# One track's own best answer, not every answer it has: a fingerprint
|
|
159
|
+
# that matches two recordings is one recording published twice, and
|
|
160
|
+
# counting both would let a single track outvote its neighbours.
|
|
161
|
+
named.update(dict.fromkeys(matches[0].release_group_ids, 1))
|
|
162
|
+
if not named:
|
|
163
|
+
# Asked and answered nothing, which is a different state from never
|
|
164
|
+
# asked and has to say so (ADR-101). Measured on his own library the
|
|
165
|
+
# day this shipped: the nine tracks of `Missinho — Esquinas de São
|
|
166
|
+
# Salvador` returned eight blanks and one fingerprint AcoustID holds
|
|
167
|
+
# with no MusicBrainz recording behind it. The service not knowing a
|
|
168
|
+
# record is the ordinary case for the catalogue he collects, and an
|
|
169
|
+
# app that goes quiet there reads as an app that did not try.
|
|
170
|
+
self._logger.info(
|
|
171
|
+
"The audio was asked and the service does not know this album.",
|
|
172
|
+
extra={"operation": "acoustic.unknown", "tracks": answered},
|
|
173
|
+
)
|
|
174
|
+
return ()
|
|
175
|
+
best = max(named.values())
|
|
176
|
+
agreed = [group for group, count in named.items() if count == best]
|
|
177
|
+
if best < 2 and answered > 1:
|
|
178
|
+
# Every sampled track named a different album, which is what a
|
|
179
|
+
# folder of unrelated files looks like. Saying so is more useful
|
|
180
|
+
# than browsing an arbitrary one of them.
|
|
181
|
+
self._logger.info(
|
|
182
|
+
"The audio did not agree with itself about this album.",
|
|
183
|
+
extra={"operation": "acoustic.disagreed", "tracks": answered},
|
|
184
|
+
)
|
|
185
|
+
return ()
|
|
186
|
+
return tuple(sorted(agreed)[: self._group_limit])
|
|
187
|
+
|
|
188
|
+
def recognize(self, path: Path, content_signature: str) -> str | None:
|
|
189
|
+
"""Put one file to the service because he asked, and keep what came back.
|
|
190
|
+
|
|
191
|
+
The identification path asks about an *album*, and only when its names
|
|
192
|
+
failed (ADR-123, ADR-125) — which is why 29 of his 2,073 registered
|
|
193
|
+
files had ever been asked. This is the other door: one file, on a gesture
|
|
194
|
+
he made, so the bench can find out what the service knows about the
|
|
195
|
+
catalogue he actually collects (ADR-155).
|
|
196
|
+
|
|
197
|
+
Returns the recording it named, or ``None`` for every other outcome. The
|
|
198
|
+
answer is written down either way, and *that* is what the screens read —
|
|
199
|
+
a return value cannot tell "asked and unknown" from "could not ask".
|
|
200
|
+
"""
|
|
201
|
+
if not self._acoustid.is_configured:
|
|
202
|
+
return None
|
|
203
|
+
probe = _OneFile(path=path, content_signature=content_signature)
|
|
204
|
+
fingerprint = self._fingerprint(probe)
|
|
205
|
+
if fingerprint is None:
|
|
206
|
+
return None
|
|
207
|
+
try:
|
|
208
|
+
matches = self._acoustid.lookup(fingerprint)
|
|
209
|
+
except Exception:
|
|
210
|
+
self._logger.exception(
|
|
211
|
+
"One file could not be put to the service.",
|
|
212
|
+
extra={"operation": "acoustic.lookup.failure"},
|
|
213
|
+
)
|
|
214
|
+
return None
|
|
215
|
+
self._remember_answer(probe, matches[0] if matches else None)
|
|
216
|
+
if matches and matches[0].recording_ids:
|
|
217
|
+
return matches[0].recording_ids[0]
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
def _remember_answer(self, file: object, match: AcousticMatch | None) -> None:
|
|
221
|
+
"""Keep what the service answered about this file, including nothing at all.
|
|
222
|
+
|
|
223
|
+
A lookup costs a request against a service that allows about three a
|
|
224
|
+
second, so its answer is worth exactly as much as the fingerprint it was
|
|
225
|
+
drawn from — and the fingerprint was the only half being kept.
|
|
226
|
+
|
|
227
|
+
Written against the audio, not merely the shape: an answer filed under a
|
|
228
|
+
signature two recordings share is an answer one of them is wearing
|
|
229
|
+
without having earned it (migration 19).
|
|
230
|
+
"""
|
|
231
|
+
signature = getattr(file, "content_signature", "")
|
|
232
|
+
if self._store is None or not signature:
|
|
233
|
+
return
|
|
234
|
+
recording = match.recording_ids[0] if match and match.recording_ids else None
|
|
235
|
+
self._store.remember_lookup(
|
|
236
|
+
signature, self._audio_key(file), recording, match.score if match else None
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def _sample(self, files: Sequence[object]) -> tuple[object, ...]:
|
|
240
|
+
"""Return tracks spread across the album rather than its first few.
|
|
241
|
+
|
|
242
|
+
A compilation's opening tracks are the ones most likely to appear on
|
|
243
|
+
other compilations; taking the first, a middle and the last asks about
|
|
244
|
+
three different points of the record.
|
|
245
|
+
"""
|
|
246
|
+
if len(files) <= self._sample_size:
|
|
247
|
+
return tuple(files)
|
|
248
|
+
last = len(files) - 1
|
|
249
|
+
positions = {
|
|
250
|
+
round(index * last / (self._sample_size - 1)) for index in range(self._sample_size)
|
|
251
|
+
}
|
|
252
|
+
return tuple(files[position] for position in sorted(positions))
|
|
253
|
+
|
|
254
|
+
def _fingerprint(self, file: object) -> AudioFingerprint | None:
|
|
255
|
+
"""Return this file's fingerprint, from the store when it is known to be this file's.
|
|
256
|
+
|
|
257
|
+
The audio is named first because naming it is nearly free — 0.4 ms for a
|
|
258
|
+
FLAC that declares its own digest, 3.7 ms for one that does not, against
|
|
259
|
+
121 ms to fingerprint the file afresh, both measured on his own `The
|
|
260
|
+
Griot` pair on 2026-08-02. A stored print is reused only when it was
|
|
261
|
+
drawn from this same audio; a print left over from before the audio had
|
|
262
|
+
a name is recomputed rather than trusted, and comes back to claim that
|
|
263
|
+
row when it proves to be the same (migration 19).
|
|
264
|
+
"""
|
|
265
|
+
signature = getattr(file, "content_signature", "")
|
|
266
|
+
key = self._audio_key(file)
|
|
267
|
+
if self._store is not None and signature:
|
|
268
|
+
remembered = self._store.fingerprint_for(signature, key)
|
|
269
|
+
if remembered is not None:
|
|
270
|
+
return remembered
|
|
271
|
+
computed = self._fingerprinter.fingerprint(getattr(file, "path", file))
|
|
272
|
+
if computed is not None and self._store is not None and signature:
|
|
273
|
+
self._store.remember_fingerprint(signature, key, computed)
|
|
274
|
+
return computed
|
|
275
|
+
|
|
276
|
+
def _audio_key(self, file: object) -> str | None:
|
|
277
|
+
"""Return the identity of this file's audio, or ``None`` when it cannot be read.
|
|
278
|
+
|
|
279
|
+
Memoized per file because both halves of one visit ask for it — the
|
|
280
|
+
print is looked up before the service is asked and the answer is written
|
|
281
|
+
after — and reading the same 128 KB twice for one lookup is waste with
|
|
282
|
+
no upside.
|
|
283
|
+
"""
|
|
284
|
+
path = getattr(file, "path", file)
|
|
285
|
+
if path in self._keys:
|
|
286
|
+
return self._keys[path]
|
|
287
|
+
key = self._audio_key_of(path) if isinstance(path, Path) else None
|
|
288
|
+
self._keys[path] = key
|
|
289
|
+
return key
|