video-duplicate 1.0.0__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.
- video_duplicate/__init__.py +8 -0
- video_duplicate/audio.py +68 -0
- video_duplicate/file.py +79 -0
- video_duplicate/hashing.py +75 -0
- video_duplicate/utils.py +22 -0
- video_duplicate-1.0.0.dist-info/METADATA +59 -0
- video_duplicate-1.0.0.dist-info/RECORD +9 -0
- video_duplicate-1.0.0.dist-info/WHEEL +4 -0
- video_duplicate-1.0.0.dist-info/entry_points.txt +3 -0
video_duplicate/audio.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# audio .py
|
|
2
|
+
from functools import lru_cache
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import acoustid
|
|
6
|
+
|
|
7
|
+
type Fingerprint = tuple[float, bytes]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@lru_cache(maxsize=1024)
|
|
11
|
+
def acoustic_fingerprint(
|
|
12
|
+
path: Path | str, fingerprint_length: int = 60
|
|
13
|
+
) -> Fingerprint | None:
|
|
14
|
+
"""
|
|
15
|
+
Generate a Chromaprint fingerprint locally.
|
|
16
|
+
|
|
17
|
+
Returns: (duration, fingerprint) || None if fingerprinting failed
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
duration, fingerprint = acoustid.fingerprint_file(
|
|
22
|
+
str(path),
|
|
23
|
+
maxlength=fingerprint_length,
|
|
24
|
+
force_fpcalc=True,
|
|
25
|
+
)
|
|
26
|
+
except Exception:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
if fingerprint is None:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
return float(duration), fingerprint
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def compare_acoustic_fingerprints(
|
|
36
|
+
fingerprint1: Fingerprint | None,
|
|
37
|
+
fingerprint2: Fingerprint | None,
|
|
38
|
+
) -> float | str:
|
|
39
|
+
"""Compare two locally generated Chromaprint fingerprints."""
|
|
40
|
+
|
|
41
|
+
if fingerprint1 is None:
|
|
42
|
+
return "Could not fingerprint video 1"
|
|
43
|
+
|
|
44
|
+
if fingerprint2 is None:
|
|
45
|
+
return "Could not fingerprint video 2."
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
similarity = acoustid.compare_fingerprints(
|
|
49
|
+
fingerprint1,
|
|
50
|
+
fingerprint2,
|
|
51
|
+
)
|
|
52
|
+
except Exception as exc:
|
|
53
|
+
return f"Fingerprint comparison failed: {exc}"
|
|
54
|
+
|
|
55
|
+
return similarity
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def acoustic_compare_two_videos(
|
|
59
|
+
path1: Path, path2: Path, fingerprint_length: int = 60
|
|
60
|
+
) -> float | str:
|
|
61
|
+
"""
|
|
62
|
+
Fingerprint two videos and compare their audio.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
fingerprint1 = acoustic_fingerprint(path1, fingerprint_length=fingerprint_length)
|
|
66
|
+
fingerprint2 = acoustic_fingerprint(path2, fingerprint_length=fingerprint_length)
|
|
67
|
+
|
|
68
|
+
return compare_acoustic_fingerprints(fingerprint1, fingerprint2)
|
video_duplicate/file.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# file.py
|
|
2
|
+
|
|
3
|
+
from itertools import combinations
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .utils import get_duration
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load_probable_duplicate(folders: list[Path], tolerance=2.0):
|
|
10
|
+
"""Find probable duplicate videos across multiple folders"""
|
|
11
|
+
|
|
12
|
+
videos_by_folder = [load_videos_from_folder(folder) for folder in folders]
|
|
13
|
+
durations = {
|
|
14
|
+
video: get_duration(video) for videos in videos_by_folder for video in videos
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
duplicates = []
|
|
18
|
+
|
|
19
|
+
for videos1, videos2 in combinations(videos_by_folder, 2):
|
|
20
|
+
for video1 in videos1:
|
|
21
|
+
duration1 = durations[video1]
|
|
22
|
+
|
|
23
|
+
for video2 in videos2:
|
|
24
|
+
duration2 = durations[video2]
|
|
25
|
+
|
|
26
|
+
if abs(duration1 - duration2) <= tolerance:
|
|
27
|
+
duplicates.append([video1, video2])
|
|
28
|
+
|
|
29
|
+
return duplicates
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_videos_from_folder(path: Path):
|
|
33
|
+
"""From a folder load all videos"""
|
|
34
|
+
|
|
35
|
+
videos = []
|
|
36
|
+
files = list(path.iterdir())
|
|
37
|
+
for file in files:
|
|
38
|
+
p = Path(file)
|
|
39
|
+
if p.suffix.lower() in VIDEO_EXTENSIONS:
|
|
40
|
+
videos.append(file)
|
|
41
|
+
return videos
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
VIDEO_EXTENSIONS = {
|
|
45
|
+
".3g2",
|
|
46
|
+
".3gp",
|
|
47
|
+
".asf",
|
|
48
|
+
".avi",
|
|
49
|
+
".divx",
|
|
50
|
+
".dv",
|
|
51
|
+
".f4v",
|
|
52
|
+
".flv",
|
|
53
|
+
".m1v",
|
|
54
|
+
".m2p",
|
|
55
|
+
".m2t",
|
|
56
|
+
".m2ts",
|
|
57
|
+
".m2v",
|
|
58
|
+
".m4v",
|
|
59
|
+
".mkv",
|
|
60
|
+
".mod",
|
|
61
|
+
".mov",
|
|
62
|
+
".mp2",
|
|
63
|
+
".mp4",
|
|
64
|
+
".mpe",
|
|
65
|
+
".mpeg",
|
|
66
|
+
".mpg",
|
|
67
|
+
".mts",
|
|
68
|
+
".mxf",
|
|
69
|
+
".ogm",
|
|
70
|
+
".ogv",
|
|
71
|
+
".qt",
|
|
72
|
+
".rm",
|
|
73
|
+
".rmvb",
|
|
74
|
+
".tod",
|
|
75
|
+
".ts",
|
|
76
|
+
".vob",
|
|
77
|
+
".webm",
|
|
78
|
+
".wmv",
|
|
79
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# hashing.py
|
|
2
|
+
import hashlib
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from PIL import Image
|
|
6
|
+
|
|
7
|
+
# Compatibility for libraries still using Image.ANTIALIAS
|
|
8
|
+
if not hasattr(Image, "ANTIALIAS"):
|
|
9
|
+
Image.ANTIALIAS = Image.Resampling.LANCZOS # type: ignore
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
from videohash import VideoHash
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def hash_first_mb_comparison(path, size=1024 * 1024):
|
|
16
|
+
h = hashlib.sha256()
|
|
17
|
+
with open(path, "rb") as f:
|
|
18
|
+
h.update(f.read(size))
|
|
19
|
+
return h.digest()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_video_phash(filepath: Path | str, frame_interval=2):
|
|
23
|
+
"""
|
|
24
|
+
Safely generates a perceptual hash for a video file.
|
|
25
|
+
Returns the hexadecimal hash string, or None if it fails.
|
|
26
|
+
"""
|
|
27
|
+
try:
|
|
28
|
+
# The library creates temporary directories for frame extraction.
|
|
29
|
+
# It usually cleans them up, but we wrap in a try-except to be safe.
|
|
30
|
+
vh = VideoHash(str(filepath), frame_interval=frame_interval)
|
|
31
|
+
return vh.hash_hex
|
|
32
|
+
except Exception as e:
|
|
33
|
+
print(f"[!] Error hashing {filepath}: {e}")
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def are_videos_duplicates(hash_hex_1, hash_hex_2, tolerance=4):
|
|
38
|
+
"""
|
|
39
|
+
Compares two hexadecimal hashes.
|
|
40
|
+
Returns True if the hamming distance is <= tolerance.
|
|
41
|
+
"""
|
|
42
|
+
if not hash_hex_1 or not hash_hex_2:
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
# Convert hex strings to integers
|
|
46
|
+
int1 = int(hash_hex_1, 16)
|
|
47
|
+
int2 = int(hash_hex_2, 16)
|
|
48
|
+
|
|
49
|
+
# XOR the two integers, then count the '1' bits to get the Hamming distance
|
|
50
|
+
hamming_distance = bin(int1 ^ int2).count("1")
|
|
51
|
+
|
|
52
|
+
return hamming_distance <= tolerance
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def check_if_duplicate_videos_phash(
|
|
56
|
+
path1: Path | str, path2: Path | str, tolerance=4, frame_interval=2
|
|
57
|
+
):
|
|
58
|
+
"""
|
|
59
|
+
Takes two video paths, generates their perceptual hashes,
|
|
60
|
+
and returns True if they are duplicates within the given tolerance.
|
|
61
|
+
"""
|
|
62
|
+
# 1. Generate hashes for both videos
|
|
63
|
+
hash1 = get_video_phash(path1, frame_interval=frame_interval)
|
|
64
|
+
hash2 = get_video_phash(path2, frame_interval=frame_interval)
|
|
65
|
+
|
|
66
|
+
# 2. Compare the hex strings using the existing function
|
|
67
|
+
return are_videos_duplicates(hash1, hash2, tolerance)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def check_if_video_duplicate_quick_hash(
|
|
71
|
+
path1: Path | str, path2: Path | str, size=1024 * 1024
|
|
72
|
+
):
|
|
73
|
+
ha = hash_first_mb_comparison(path1, size=size)
|
|
74
|
+
hb = hash_first_mb_comparison(path2, size=size)
|
|
75
|
+
return ha == hb
|
video_duplicate/utils.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def get_duration(path: Path | str):
|
|
6
|
+
result = subprocess.run(
|
|
7
|
+
[
|
|
8
|
+
"ffprobe",
|
|
9
|
+
"-v",
|
|
10
|
+
"error",
|
|
11
|
+
"-show_entries",
|
|
12
|
+
"format=duration",
|
|
13
|
+
"-of",
|
|
14
|
+
"default=noprint_wrappers=1:nokey=1",
|
|
15
|
+
str(path),
|
|
16
|
+
],
|
|
17
|
+
capture_output=True,
|
|
18
|
+
text=True,
|
|
19
|
+
check=True,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
return float(result.stdout.strip())
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: video-duplicate
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Find duplicate videos across folders
|
|
5
|
+
Author: ritonun
|
|
6
|
+
Author-email: ritonun <you@example.com>
|
|
7
|
+
Requires-Dist: pillow>=12.3.0
|
|
8
|
+
Requires-Dist: pyacoustid>=1.3.1
|
|
9
|
+
Requires-Dist: videohash>=3.0.1
|
|
10
|
+
Requires-Python: >=3.13
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# Video Duplicate
|
|
14
|
+
|
|
15
|
+
Tool to detect duplicate videos.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
Dependencies:
|
|
19
|
+
```bash
|
|
20
|
+
sudo apt install libchromaprint-tools
|
|
21
|
+
```
|
|
22
|
+
Python dependencies:
|
|
23
|
+
```bash
|
|
24
|
+
pip install pyacoustid videohash pillow
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
```python
|
|
29
|
+
folders = [Path("data/dup"), Path("data/test")]
|
|
30
|
+
duplicates = load_probable_duplicate(folders, tolerance=2.0)
|
|
31
|
+
print(f"Found {len(duplicates)} duplicates")
|
|
32
|
+
|
|
33
|
+
for duplicate in duplicates:
|
|
34
|
+
v1, v2 = duplicate
|
|
35
|
+
if check_if_video_duplicate_quick_hash(v1, v2, size=1024 * 1024):
|
|
36
|
+
# check if the first 1MB (size) has the same hash between the two videos
|
|
37
|
+
print(f"{v1} & {v2} are duplicate.")
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
# compare audio between the two video
|
|
41
|
+
similarity = acoustic_compare_two_videos(v1, v2)
|
|
42
|
+
if isinstance(similarity, str):
|
|
43
|
+
print(f"error: {similarity}")
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
if similarity > 0.9:
|
|
47
|
+
print(f"{v1} & {v2} are duplicate.")
|
|
48
|
+
continue
|
|
49
|
+
elif similarity > 0.8:
|
|
50
|
+
print(f"{v1} & {v2} are likely duplicate.")
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
if check_if_duplicate_videos_phash(v1, v2):
|
|
54
|
+
# best method but very long/computer intensive
|
|
55
|
+
print(f"{v1} & {v2} are duplicate.")
|
|
56
|
+
|
|
57
|
+
else:
|
|
58
|
+
print(f"{v1} & {v2} are NOT duplicate.")
|
|
59
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
video_duplicate/__init__.py,sha256=tLmeWsDk5o6Fwn5PzPO1DP3cVd78lAJy3DPuoINNXaQ,232
|
|
2
|
+
video_duplicate/audio.py,sha256=Ektz078bDqzgpBMojXId8kGhuacsuLQGNGZ0wHMFuwE,1703
|
|
3
|
+
video_duplicate/file.py,sha256=he5UquvFp3Bl-RsbnqInUjybKrVg1dlad9d0xReVpMQ,1518
|
|
4
|
+
video_duplicate/hashing.py,sha256=UgK2FuQagl9fUUiPpxbvU2iisfCVwzsDM0a2vdPA0Us,2287
|
|
5
|
+
video_duplicate/utils.py,sha256=jyCdip02K66FNgGFDrBhvrQiFF-W0kXnDqHNOleqhKs,460
|
|
6
|
+
video_duplicate-1.0.0.dist-info/WHEEL,sha256=e4_1dyBeezi8ZjfxrZ3bnVOxFDa3ksqVqH0jTHkUZ3k,81
|
|
7
|
+
video_duplicate-1.0.0.dist-info/entry_points.txt,sha256=5AuLXL5QqqfWSvRw-unaNISU46RZz4jCKrP6LawUO-I,58
|
|
8
|
+
video_duplicate-1.0.0.dist-info/METADATA,sha256=_T1jsWWxe1JnZRY7eiMo6tT3-O_wsK1Snu-tKzdxi4c,1545
|
|
9
|
+
video_duplicate-1.0.0.dist-info/RECORD,,
|