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.
- similar_files-0.1.0/LICENSE +21 -0
- similar_files-0.1.0/PKG-INFO +96 -0
- similar_files-0.1.0/README.md +72 -0
- similar_files-0.1.0/pyproject.toml +42 -0
- similar_files-0.1.0/setup.cfg +4 -0
- similar_files-0.1.0/similar_files/__init__.py +51 -0
- similar_files-0.1.0/similar_files/__main__.py +3 -0
- similar_files-0.1.0/similar_files/_parallel.py +112 -0
- similar_files-0.1.0/similar_files/audio.py +115 -0
- similar_files-0.1.0/similar_files/cache.py +389 -0
- similar_files-0.1.0/similar_files/cli.py +276 -0
- similar_files-0.1.0/similar_files/identical.py +294 -0
- similar_files-0.1.0/similar_files/image.py +75 -0
- similar_files-0.1.0/similar_files/model.py +86 -0
- similar_files-0.1.0/similar_files/playlist.py +176 -0
- similar_files-0.1.0/similar_files/registry.py +200 -0
- similar_files-0.1.0/similar_files/similar.py +377 -0
- similar_files-0.1.0/similar_files/source.py +264 -0
- similar_files-0.1.0/similar_files/video.py +159 -0
- similar_files-0.1.0/similar_files.egg-info/PKG-INFO +96 -0
- similar_files-0.1.0/similar_files.egg-info/SOURCES.txt +33 -0
- similar_files-0.1.0/similar_files.egg-info/dependency_links.txt +1 -0
- similar_files-0.1.0/similar_files.egg-info/entry_points.txt +2 -0
- similar_files-0.1.0/similar_files.egg-info/requires.txt +7 -0
- similar_files-0.1.0/similar_files.egg-info/top_level.txt +1 -0
- similar_files-0.1.0/test/test_audio.py +68 -0
- similar_files-0.1.0/test/test_cache.py +132 -0
- similar_files-0.1.0/test/test_cli.py +95 -0
- similar_files-0.1.0/test/test_identical.py +193 -0
- similar_files-0.1.0/test/test_image.py +92 -0
- similar_files-0.1.0/test/test_playlist.py +77 -0
- similar_files-0.1.0/test/test_registry.py +60 -0
- similar_files-0.1.0/test/test_similar.py +157 -0
- similar_files-0.1.0/test/test_video.py +92 -0
- 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,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,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
|