similar-files 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.
Files changed (35) hide show
  1. similar_files-0.1.0/LICENSE +21 -0
  2. similar_files-0.1.0/PKG-INFO +96 -0
  3. similar_files-0.1.0/README.md +72 -0
  4. similar_files-0.1.0/pyproject.toml +42 -0
  5. similar_files-0.1.0/setup.cfg +4 -0
  6. similar_files-0.1.0/similar_files/__init__.py +51 -0
  7. similar_files-0.1.0/similar_files/__main__.py +3 -0
  8. similar_files-0.1.0/similar_files/_parallel.py +112 -0
  9. similar_files-0.1.0/similar_files/audio.py +115 -0
  10. similar_files-0.1.0/similar_files/cache.py +389 -0
  11. similar_files-0.1.0/similar_files/cli.py +276 -0
  12. similar_files-0.1.0/similar_files/identical.py +294 -0
  13. similar_files-0.1.0/similar_files/image.py +75 -0
  14. similar_files-0.1.0/similar_files/model.py +86 -0
  15. similar_files-0.1.0/similar_files/playlist.py +176 -0
  16. similar_files-0.1.0/similar_files/registry.py +200 -0
  17. similar_files-0.1.0/similar_files/similar.py +377 -0
  18. similar_files-0.1.0/similar_files/source.py +264 -0
  19. similar_files-0.1.0/similar_files/video.py +159 -0
  20. similar_files-0.1.0/similar_files.egg-info/PKG-INFO +96 -0
  21. similar_files-0.1.0/similar_files.egg-info/SOURCES.txt +33 -0
  22. similar_files-0.1.0/similar_files.egg-info/dependency_links.txt +1 -0
  23. similar_files-0.1.0/similar_files.egg-info/entry_points.txt +2 -0
  24. similar_files-0.1.0/similar_files.egg-info/requires.txt +7 -0
  25. similar_files-0.1.0/similar_files.egg-info/top_level.txt +1 -0
  26. similar_files-0.1.0/test/test_audio.py +68 -0
  27. similar_files-0.1.0/test/test_cache.py +132 -0
  28. similar_files-0.1.0/test/test_cli.py +95 -0
  29. similar_files-0.1.0/test/test_identical.py +193 -0
  30. similar_files-0.1.0/test/test_image.py +92 -0
  31. similar_files-0.1.0/test/test_playlist.py +77 -0
  32. similar_files-0.1.0/test/test_registry.py +60 -0
  33. similar_files-0.1.0/test/test_similar.py +157 -0
  34. similar_files-0.1.0/test/test_video.py +92 -0
  35. similar_files-0.1.0/test/test_walk.py +78 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 craftware
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,96 @@
1
+ Metadata-Version: 2.4
2
+ Name: similar-files
3
+ Version: 0.1.0
4
+ Summary: Find identical and similar files, and write each group as a playlist
5
+ Author: craftware
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/crftwr/similar-files
8
+ Project-URL: Repository, https://github.com/crftwr/similar-files
9
+ Classifier: Development Status :: 2 - Pre-Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: System :: Filesystems
14
+ Classifier: Topic :: Utilities
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Provides-Extra: image
19
+ Requires-Dist: Pillow>=10; extra == "image"
20
+ Requires-Dist: ImageHash>=4.3; extra == "image"
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest; extra == "test"
23
+ Dynamic: license-file
24
+
25
+ # similar-files
26
+
27
+ Find files that are the same, or that look the same, and get each group as a
28
+ playlist you can open anywhere.
29
+
30
+ > **Status: early development.** The command line and the library may still
31
+ > change between releases.
32
+
33
+ ## What it finds
34
+
35
+ - **Identical files**: the same bytes under different names or in different
36
+ folders. Needs nothing beyond Python.
37
+ - **Similar images**: the same picture resized, re-encoded or lightly edited.
38
+ Uses an optional dependency.
39
+ - **Similar videos**: the same video re-encoded, resized or re-muxed. Needs
40
+ [ffmpeg](https://ffmpeg.org) on your `PATH`.
41
+ - **Similar audio**: the same recording re-encoded, or with its tags changed.
42
+ Needs `fpcalc` from [Chromaprint](https://acoustid.org/chromaprint) on your
43
+ `PATH` (`brew install chromaprint`, `apt install libchromaprint-tools`).
44
+
45
+ Content-aware matching ("screenshots of receipts") is planned for later.
46
+
47
+ ## How it works
48
+
49
+ ```bash
50
+ pip install similar-files # identical files only
51
+ pip install "similar-files[image]" # plus similar images
52
+
53
+ similar-files scan ~/Pictures --out ~/similar-groups # identical files
54
+ similar-files scan ~/Pictures --out ~/similar-images -m image # similar images
55
+ similar-files scan ~/Movies --out ~/similar-videos -m video # similar videos
56
+ similar-files scan ~/Music --out ~/similar-music -m audio # similar audio
57
+ similar-files scan ~/Pictures --out ~/copies -r ~/Desktop/logo.png # copies of one file
58
+ ```
59
+
60
+ From a checkout, run `python -m similar_files` instead of `similar-files`.
61
+ `--threshold` (0–1) sets how close a match must be, and
62
+ `similar-files extractors` lists what can be compared.
63
+
64
+ Each group becomes one `.m3u8` playlist in the output folder, and its first
65
+ line is the file the others were compared with:
66
+
67
+ ```
68
+ ~/similar-groups/
69
+ IMG_0012.jpg (3).m3u8
70
+ logo_final.png (2).m3u8
71
+ ```
72
+
73
+ M3U is a plain, widely supported list format, so you review a group in
74
+ whatever tool you like: a media player,
75
+ [XeFM](https://github.com/crftwr/xefm) (press Enter on a playlist to see its
76
+ files in a pane and copy, move or delete them there), or a text editor.
77
+
78
+ Two guarantees:
79
+
80
+ - **similar-files never deletes, moves or changes your files.** It only reads
81
+ them, and you decide what to keep.
82
+ - **It writes nothing next to your files.** Features it computes are cached in
83
+ your user cache folder, so a second scan is fast. You can delete that cache
84
+ at any time.
85
+
86
+ ## Using it from Python
87
+
88
+ The command line is a thin wrapper. The scanning, caching and grouping are a
89
+ library that other programs can call, and XeFM is the first program to use
90
+ it. See [doc/LIBRARY.md](https://github.com/crftwr/similar-files/blob/main/doc/LIBRARY.md), and for the formats other programs
91
+ read, [doc/PLAYLIST_FORMAT.md](https://github.com/crftwr/similar-files/blob/main/doc/PLAYLIST_FORMAT.md) and
92
+ [doc/CACHE.md](https://github.com/crftwr/similar-files/blob/main/doc/CACHE.md).
93
+
94
+ ## License
95
+
96
+ MIT. See [LICENSE](https://github.com/crftwr/similar-files/blob/main/LICENSE).
@@ -0,0 +1,72 @@
1
+ # similar-files
2
+
3
+ Find files that are the same, or that look the same, and get each group as a
4
+ playlist you can open anywhere.
5
+
6
+ > **Status: early development.** The command line and the library may still
7
+ > change between releases.
8
+
9
+ ## What it finds
10
+
11
+ - **Identical files**: the same bytes under different names or in different
12
+ folders. Needs nothing beyond Python.
13
+ - **Similar images**: the same picture resized, re-encoded or lightly edited.
14
+ Uses an optional dependency.
15
+ - **Similar videos**: the same video re-encoded, resized or re-muxed. Needs
16
+ [ffmpeg](https://ffmpeg.org) on your `PATH`.
17
+ - **Similar audio**: the same recording re-encoded, or with its tags changed.
18
+ Needs `fpcalc` from [Chromaprint](https://acoustid.org/chromaprint) on your
19
+ `PATH` (`brew install chromaprint`, `apt install libchromaprint-tools`).
20
+
21
+ Content-aware matching ("screenshots of receipts") is planned for later.
22
+
23
+ ## How it works
24
+
25
+ ```bash
26
+ pip install similar-files # identical files only
27
+ pip install "similar-files[image]" # plus similar images
28
+
29
+ similar-files scan ~/Pictures --out ~/similar-groups # identical files
30
+ similar-files scan ~/Pictures --out ~/similar-images -m image # similar images
31
+ similar-files scan ~/Movies --out ~/similar-videos -m video # similar videos
32
+ similar-files scan ~/Music --out ~/similar-music -m audio # similar audio
33
+ similar-files scan ~/Pictures --out ~/copies -r ~/Desktop/logo.png # copies of one file
34
+ ```
35
+
36
+ From a checkout, run `python -m similar_files` instead of `similar-files`.
37
+ `--threshold` (0–1) sets how close a match must be, and
38
+ `similar-files extractors` lists what can be compared.
39
+
40
+ Each group becomes one `.m3u8` playlist in the output folder, and its first
41
+ line is the file the others were compared with:
42
+
43
+ ```
44
+ ~/similar-groups/
45
+ IMG_0012.jpg (3).m3u8
46
+ logo_final.png (2).m3u8
47
+ ```
48
+
49
+ M3U is a plain, widely supported list format, so you review a group in
50
+ whatever tool you like: a media player,
51
+ [XeFM](https://github.com/crftwr/xefm) (press Enter on a playlist to see its
52
+ files in a pane and copy, move or delete them there), or a text editor.
53
+
54
+ Two guarantees:
55
+
56
+ - **similar-files never deletes, moves or changes your files.** It only reads
57
+ them, and you decide what to keep.
58
+ - **It writes nothing next to your files.** Features it computes are cached in
59
+ your user cache folder, so a second scan is fast. You can delete that cache
60
+ at any time.
61
+
62
+ ## Using it from Python
63
+
64
+ The command line is a thin wrapper. The scanning, caching and grouping are a
65
+ library that other programs can call, and XeFM is the first program to use
66
+ it. See [doc/LIBRARY.md](https://github.com/crftwr/similar-files/blob/main/doc/LIBRARY.md), and for the formats other programs
67
+ read, [doc/PLAYLIST_FORMAT.md](https://github.com/crftwr/similar-files/blob/main/doc/PLAYLIST_FORMAT.md) and
68
+ [doc/CACHE.md](https://github.com/crftwr/similar-files/blob/main/doc/CACHE.md).
69
+
70
+ ## License
71
+
72
+ MIT. See [LICENSE](https://github.com/crftwr/similar-files/blob/main/LICENSE).
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "similar-files"
7
+ description = "Find identical and similar files, and write each group as a playlist"
8
+ readme = "README.md"
9
+ license = "MIT"
10
+ authors = [{ name = "craftware" }]
11
+ requires-python = ">=3.10"
12
+ dynamic = ["version"]
13
+ # Core: standard library only. Layer 1 (identical files) needs nothing more.
14
+ dependencies = []
15
+ classifiers = [
16
+ "Development Status :: 2 - Pre-Alpha",
17
+ "Environment :: Console",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: System :: Filesystems",
21
+ "Topic :: Utilities",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ image = ["Pillow>=10", "ImageHash>=4.3"]
26
+ test = ["pytest"]
27
+
28
+ [project.scripts]
29
+ similar-files = "similar_files.cli:main"
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/crftwr/similar-files"
33
+ Repository = "https://github.com/crftwr/similar-files"
34
+
35
+ [tool.setuptools.packages.find]
36
+ include = ["similar_files*"]
37
+
38
+ [tool.setuptools.dynamic]
39
+ version = { attr = "similar_files.__version__" }
40
+
41
+ [tool.pytest.ini_options]
42
+ testpaths = ["test"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,51 @@
1
+ """similar-files: find files that are the same, or look the same, and write each group as a playlist.
2
+
3
+ The public API is what this module exports. Everything else may change.
4
+ """
5
+
6
+ from .cache import Cache, CacheError, default_cache_dir, resolve_cache_path
7
+ from .identical import find_identical
8
+ from .model import Group, Member, Progress, ScanResult
9
+ from .playlist import our_playlists, read_playlist, remove_playlists, write_playlists
10
+ from .registry import (
11
+ ExtractionFailed,
12
+ Extractor,
13
+ ExtractorUnavailable,
14
+ extractors,
15
+ get_extractor,
16
+ register,
17
+ )
18
+ from .similar import FeatureSet, extract_features, find_similar, group_features
19
+ from .source import FileItem, LocalFile, WalkStats, walk
20
+
21
+ __version__ = "0.1.0"
22
+
23
+ __all__ = [
24
+ "Cache",
25
+ "CacheError",
26
+ "ExtractionFailed",
27
+ "Extractor",
28
+ "ExtractorUnavailable",
29
+ "FeatureSet",
30
+ "FileItem",
31
+ "Group",
32
+ "LocalFile",
33
+ "Member",
34
+ "Progress",
35
+ "ScanResult",
36
+ "WalkStats",
37
+ "default_cache_dir",
38
+ "extract_features",
39
+ "extractors",
40
+ "find_identical",
41
+ "find_similar",
42
+ "get_extractor",
43
+ "group_features",
44
+ "our_playlists",
45
+ "read_playlist",
46
+ "register",
47
+ "remove_playlists",
48
+ "resolve_cache_path",
49
+ "walk",
50
+ "write_playlists",
51
+ ]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,112 @@
1
+ """Thread pools for extraction, with cancellation and progress on the caller's thread."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import concurrent.futures as cf
6
+ import os
7
+ import threading
8
+ from typing import Callable, Optional, TypeVar
9
+
10
+ from .model import CancelCheck, Progress, ProgressCallback
11
+
12
+ T = TypeVar("T")
13
+
14
+ #: Workers for local files: capped by the CPU count.
15
+ DEFAULT_MAX_WORKERS = 8
16
+ #: Workers for remote files: a server is not a CPU.
17
+ DEFAULT_REMOTE_WORKERS = 4
18
+
19
+ _POLL_SECONDS = 0.1
20
+
21
+
22
+ def default_workers() -> int:
23
+ return max(1, min(DEFAULT_MAX_WORKERS, os.cpu_count() or 1))
24
+
25
+
26
+ class Runner:
27
+ """Two thread pools (local and remote items), a cancel flag, and progress counting.
28
+
29
+ Jobs run on the pools; waiting, progress callbacks and the cancel check
30
+ run on the thread that owns the runner.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ *,
36
+ workers: Optional[int] = None,
37
+ remote_workers: Optional[int] = None,
38
+ progress: Optional[ProgressCallback] = None,
39
+ cancel: Optional[CancelCheck] = None,
40
+ ):
41
+ self._local = cf.ThreadPoolExecutor(max_workers=workers or default_workers(), thread_name_prefix="sf-local")
42
+ self._remote_workers = remote_workers or DEFAULT_REMOTE_WORKERS
43
+ self._remote: Optional[cf.ThreadPoolExecutor] = None
44
+ self._progress = progress
45
+ self._cancel = cancel
46
+ #: Set when the scan is cancelled; workers check it between chunks.
47
+ self.stop = threading.Event()
48
+ self._lock = threading.Lock()
49
+ self._phase = ""
50
+ self._done = 0
51
+ self._total = 0
52
+ self._reported = (None, -1, -1)
53
+
54
+ def submit(self, remote: bool, fn: Callable[..., T], *args) -> "cf.Future[T]":
55
+ if remote:
56
+ if self._remote is None:
57
+ self._remote = cf.ThreadPoolExecutor(max_workers=self._remote_workers, thread_name_prefix="sf-remote")
58
+ pool = self._remote
59
+ else:
60
+ pool = self._local
61
+ future = pool.submit(fn, *args)
62
+ future.add_done_callback(self._count)
63
+ return future
64
+
65
+ def start_phase(self, phase: str, total: int) -> None:
66
+ with self._lock:
67
+ self._phase, self._done, self._total = phase, 0, total
68
+ self.report()
69
+
70
+ def _count(self, _future) -> None:
71
+ with self._lock:
72
+ self._done += 1
73
+
74
+ def report(self) -> None:
75
+ if self._progress is None:
76
+ return
77
+ with self._lock:
78
+ state = (self._phase, self._done, self._total)
79
+ if state != self._reported:
80
+ self._reported = state
81
+ self._progress(Progress(*state))
82
+
83
+ def cancelled(self) -> bool:
84
+ """Poll the caller's cancel check; once true, stays true."""
85
+ if not self.stop.is_set() and self._cancel is not None and self._cancel():
86
+ self.stop.set()
87
+ return self.stop.is_set()
88
+
89
+ def wait(self, futures) -> bool:
90
+ """Wait for ``futures``, reporting progress. False if the scan was cancelled first."""
91
+ pending = set(futures)
92
+ while pending:
93
+ if self.cancelled():
94
+ return False
95
+ _, pending = cf.wait(pending, timeout=_POLL_SECONDS)
96
+ self.report()
97
+ return not self.cancelled()
98
+
99
+ def shutdown(self) -> None:
100
+ """Stop handing out work. Running jobs see :attr:`stop` if cancelled, and are not waited for."""
101
+ cancelled = self.stop.is_set()
102
+ for pool in (self._local, self._remote):
103
+ if pool is not None:
104
+ pool.shutdown(wait=not cancelled, cancel_futures=True)
105
+
106
+ def __enter__(self) -> "Runner":
107
+ return self
108
+
109
+ def __exit__(self, *exc) -> None:
110
+ if exc[0] is not None:
111
+ self.stop.set()
112
+ self.shutdown()
@@ -0,0 +1,115 @@
1
+ """Layer 2 for audio: Chromaprint fingerprints (``fpcalc``).
2
+
3
+ Needs ``fpcalc`` on ``PATH`` (Chromaprint: ``brew install chromaprint``,
4
+ ``apt install libchromaprint-tools``, or https://acoustid.org/chromaprint),
5
+ and no Python package.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import struct
13
+ import subprocess
14
+ from typing import Any, Sequence
15
+
16
+ from .registry import ExtractionFailed, Extractor, register
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _HEADER = struct.Struct("<dI")
21
+ #: Fingerprint items two recordings may be shifted by (one item ≈ 0.124 s),
22
+ #: for a copy with a little more or less silence at the start.
23
+ _MAX_SHIFT = 12
24
+ #: The overlap must cover this share of the shorter fingerprint.
25
+ _MIN_OVERLAP = 0.5
26
+ #: Durations further apart than this (seconds, or share of the longer) are
27
+ #: different recordings; cheaper than comparing the fingerprints.
28
+ _DURATION_SLACK_S = 3.0
29
+ _DURATION_SLACK = 0.1
30
+ _TIMEOUT = 120
31
+
32
+
33
+ @register
34
+ class AudioExtractor(Extractor):
35
+ """A Chromaprint fingerprint of the first ``length`` seconds, compared by bit error rate.
36
+
37
+ The fingerprints are aligned at the best of a few small shifts. The
38
+ similarity is ``1 - 2 * bit_error_rate``, clamped at 0: the same
39
+ recording re-encoded scores near 1, and unrelated audio (about half the
40
+ bits differ) near 0. Durations that differ by more than 3 s and 10%
41
+ score 0 without comparing.
42
+ """
43
+
44
+ name = "audio"
45
+ version = 1
46
+ extensions = frozenset({
47
+ ".mp3", ".m4a", ".aac", ".flac", ".wav", ".aif", ".aiff", ".ogg", ".oga",
48
+ ".opus", ".wma", ".alac", ".ape", ".wv",
49
+ })
50
+ requires_programs = ("fpcalc",)
51
+ needs_path = True
52
+ default_params = {"length": 120}
53
+ default_threshold = 0.7
54
+ description = "Similar audio: the same recording re-encoded or re-tagged (Chromaprint)"
55
+
56
+ def validate_params(self, params: dict[str, Any]) -> None:
57
+ if not 10 <= params["length"] <= 3600:
58
+ raise ValueError("length must be 10–3600 seconds")
59
+
60
+ def extract_file(self, path: str) -> bytes:
61
+ args = ["fpcalc", "-raw", "-json", "-length", str(self.params["length"]), path]
62
+ try:
63
+ proc = subprocess.run(args, stdin=subprocess.DEVNULL, capture_output=True, timeout=_TIMEOUT)
64
+ except subprocess.TimeoutExpired as exc:
65
+ raise ExtractionFailed(f"fpcalc took longer than {_TIMEOUT}s") from exc
66
+ except OSError as exc:
67
+ raise ExtractionFailed(f"cannot run fpcalc: {exc}") from exc
68
+ if proc.returncode != 0:
69
+ msg = proc.stderr.decode("utf-8", "replace").strip().splitlines()
70
+ raise ExtractionFailed(msg[-1] if msg else f"fpcalc exited with {proc.returncode}")
71
+ try:
72
+ info = json.loads(proc.stdout.decode("utf-8", "replace"))
73
+ duration = float(info["duration"])
74
+ fp = [int(v) & 0xFFFFFFFF for v in info["fingerprint"]]
75
+ except (ValueError, KeyError, TypeError) as exc:
76
+ raise ExtractionFailed(f"fpcalc gave no readable fingerprint: {exc}") from exc
77
+ if not fp:
78
+ raise ExtractionFailed("too short to fingerprint")
79
+ return _HEADER.pack(duration, len(fp)) + struct.pack(f"<{len(fp)}I", *fp)
80
+
81
+ @staticmethod
82
+ def _decode(feature: bytes) -> tuple[float, int, int]:
83
+ """``(duration, items, fingerprint as one integer)``: item ``i`` in bits ``32i…32i+31``."""
84
+ duration, n = _HEADER.unpack_from(feature)
85
+ return duration, n, int.from_bytes(feature[_HEADER.size:_HEADER.size + 4 * n], "little")
86
+
87
+ def similarity(self, a: bytes, b: bytes) -> float:
88
+ return _compare(self._decode(a), self._decode(b))
89
+
90
+ def prepare(self, features: Sequence[bytes]) -> Any:
91
+ return [self._decode(f) for f in features]
92
+
93
+ def similarities(self, prepared: Any, query: bytes) -> Sequence[float]:
94
+ q = self._decode(query)
95
+ return [_compare(q, p) for p in prepared]
96
+
97
+
98
+ def _compare(a: tuple[float, int, int], b: tuple[float, int, int]) -> float:
99
+ (da, na, fa), (db, nb, fb) = a, b
100
+ if abs(da - db) > max(_DURATION_SLACK_S, _DURATION_SLACK * max(da, db)):
101
+ return 0.0
102
+ need = max(1, int(min(na, nb) * _MIN_OVERLAP))
103
+ best = 0.0
104
+ for shift in range(-_MAX_SHIFT, _MAX_SHIFT + 1):
105
+ # Item i of one lines up with item i + shift of the other.
106
+ x, y, nx, ny = (fa, fb, na, nb) if shift >= 0 else (fb, fa, nb, na)
107
+ k = abs(shift)
108
+ overlap = min(nx - k, ny)
109
+ if overlap < need:
110
+ continue
111
+ bits = 32 * overlap
112
+ mask = (1 << bits) - 1
113
+ errors = (((x >> (32 * k)) ^ y) & mask).bit_count()
114
+ best = max(best, 1.0 - 2.0 * errors / bits)
115
+ return best