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.

Files changed (119) hide show
  1. diglibrary/__init__.py +5 -0
  2. diglibrary/__main__.py +107 -0
  3. diglibrary/application/__init__.py +1 -0
  4. diglibrary/application/acoustic.py +289 -0
  5. diglibrary/application/api.py +10060 -0
  6. diglibrary/application/artwork.py +542 -0
  7. diglibrary/application/bench.py +559 -0
  8. diglibrary/application/composition.py +625 -0
  9. diglibrary/application/contracts.py +224 -0
  10. diglibrary/application/identification.py +1636 -0
  11. diglibrary/application/quality.py +421 -0
  12. diglibrary/cachedir.py +141 -0
  13. diglibrary/config/__init__.py +6 -0
  14. diglibrary/config/credentials.py +118 -0
  15. diglibrary/config/default.toml +113 -0
  16. diglibrary/config/home.py +81 -0
  17. diglibrary/config/loader.py +319 -0
  18. diglibrary/config/models.py +111 -0
  19. diglibrary/connectors/__init__.py +36 -0
  20. diglibrary/connectors/contracts.py +155 -0
  21. diglibrary/connectors/models.py +471 -0
  22. diglibrary/connectors/slskd/__init__.py +21 -0
  23. diglibrary/connectors/slskd/client.py +584 -0
  24. diglibrary/connectors/slskd/configuration.py +76 -0
  25. diglibrary/connectors/slskd/connector.py +307 -0
  26. diglibrary/connectors/slskd/exceptions.py +239 -0
  27. diglibrary/connectors/slskd/mapper.py +343 -0
  28. diglibrary/connectors/slskd/models.py +46 -0
  29. diglibrary/database/__init__.py +6 -0
  30. diglibrary/database/connection.py +232 -0
  31. diglibrary/database/copies.py +324 -0
  32. diglibrary/database/library_store.py +2790 -0
  33. diglibrary/database/migrations.py +1133 -0
  34. diglibrary/database/repositories.py +41 -0
  35. diglibrary/database/serialization.py +163 -0
  36. diglibrary/desktop/__init__.py +15 -0
  37. diglibrary/desktop/bundle.py +161 -0
  38. diglibrary/desktop/icon.py +215 -0
  39. diglibrary/desktop/restore.py +83 -0
  40. diglibrary/engines/__init__.py +21 -0
  41. diglibrary/engines/contracts.py +61 -0
  42. diglibrary/harmonic/__init__.py +0 -0
  43. diglibrary/harmonic/analyzer.py +154 -0
  44. diglibrary/harmonic/keys.py +161 -0
  45. diglibrary/harmonic/mixing.py +100 -0
  46. diglibrary/harmonic/tempo.py +132 -0
  47. diglibrary/identity.py +17 -0
  48. diglibrary/library/__init__.py +50 -0
  49. diglibrary/library/artwork.py +653 -0
  50. diglibrary/library/atomicwrite.py +120 -0
  51. diglibrary/library/audio.py +84 -0
  52. diglibrary/library/audio_key.py +212 -0
  53. diglibrary/library/containers.py +191 -0
  54. diglibrary/library/executor.py +810 -0
  55. diglibrary/library/extras.py +184 -0
  56. diglibrary/library/fingerprint.py +147 -0
  57. diglibrary/library/hints.py +630 -0
  58. diglibrary/library/integrity.py +246 -0
  59. diglibrary/library/matching.py +1918 -0
  60. diglibrary/library/medleys.py +211 -0
  61. diglibrary/library/models.py +90 -0
  62. diglibrary/library/naming.py +1140 -0
  63. diglibrary/library/planner.py +1113 -0
  64. diglibrary/library/report.py +309 -0
  65. diglibrary/library/scanner.py +416 -0
  66. diglibrary/library/series.py +255 -0
  67. diglibrary/library/signature.py +56 -0
  68. diglibrary/library/spelling.py +202 -0
  69. diglibrary/library/tagrelease.py +329 -0
  70. diglibrary/library/tags.py +320 -0
  71. diglibrary/library/xattrs.py +194 -0
  72. diglibrary/logging/__init__.py +5 -0
  73. diglibrary/logging/setup.py +132 -0
  74. diglibrary/metadata/__init__.py +27 -0
  75. diglibrary/metadata/acoustid.py +148 -0
  76. diglibrary/metadata/authentication.py +54 -0
  77. diglibrary/metadata/cache.py +111 -0
  78. diglibrary/metadata/coverart.py +207 -0
  79. diglibrary/metadata/discogs.py +309 -0
  80. diglibrary/metadata/itunes.py +314 -0
  81. diglibrary/metadata/matching.py +24 -0
  82. diglibrary/metadata/models.py +21 -0
  83. diglibrary/metadata/musicbrainz.py +304 -0
  84. diglibrary/metadata/normalization.py +105 -0
  85. diglibrary/metadata/rate_limit.py +32 -0
  86. diglibrary/metadata/retry.py +69 -0
  87. diglibrary/metadata/service.py +187 -0
  88. diglibrary/metadata/spotify.py +284 -0
  89. diglibrary/metadata/transport.py +181 -0
  90. diglibrary/providers/__init__.py +5 -0
  91. diglibrary/providers/capabilities.py +25 -0
  92. diglibrary/providers/contracts.py +55 -0
  93. diglibrary/providers/manager.py +229 -0
  94. diglibrary/providers/models.py +162 -0
  95. diglibrary/providers/registry.py +65 -0
  96. diglibrary/providers/soulseek.py +394 -0
  97. diglibrary/providers/types.py +30 -0
  98. diglibrary/quality/__init__.py +41 -0
  99. diglibrary/quality/analysis.py +399 -0
  100. diglibrary/quality/confidence.py +199 -0
  101. diglibrary/quality/models.py +391 -0
  102. diglibrary/quality/spectrogram.py +276 -0
  103. diglibrary/quality/verdict.py +578 -0
  104. diglibrary/ui/__init__.py +1 -0
  105. diglibrary/ui/app.py +245 -0
  106. diglibrary/ui/audio.py +314 -0
  107. diglibrary/ui/drops.py +95 -0
  108. diglibrary/ui/folders.py +108 -0
  109. diglibrary/ui/wakefulness.py +83 -0
  110. diglibrary/ui/web/app.js +10845 -0
  111. diglibrary/ui/web/index.html +1183 -0
  112. diglibrary/ui/web/strings.js +1805 -0
  113. diglibrary/ui/web/styles.css +3829 -0
  114. diglibrary-1.1.1.dist-info/METADATA +452 -0
  115. diglibrary-1.1.1.dist-info/RECORD +119 -0
  116. diglibrary-1.1.1.dist-info/WHEEL +5 -0
  117. diglibrary-1.1.1.dist-info/entry_points.txt +2 -0
  118. diglibrary-1.1.1.dist-info/licenses/LICENSE +21 -0
  119. diglibrary-1.1.1.dist-info/top_level.txt +1 -0
diglibrary/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """DigLibrary's production-quality infrastructure foundation."""
2
+
3
+ from diglibrary.application.composition import Application, create_application
4
+
5
+ __all__ = ["Application", "create_application"]
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