video-duplicate 1.0.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.
@@ -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,47 @@
1
+ # Video Duplicate
2
+
3
+ Tool to detect duplicate videos.
4
+
5
+ ## Install
6
+ Dependencies:
7
+ ```bash
8
+ sudo apt install libchromaprint-tools
9
+ ```
10
+ Python dependencies:
11
+ ```bash
12
+ pip install pyacoustid videohash pillow
13
+ ```
14
+
15
+ ## Usage
16
+ ```python
17
+ folders = [Path("data/dup"), Path("data/test")]
18
+ duplicates = load_probable_duplicate(folders, tolerance=2.0)
19
+ print(f"Found {len(duplicates)} duplicates")
20
+
21
+ for duplicate in duplicates:
22
+ v1, v2 = duplicate
23
+ if check_if_video_duplicate_quick_hash(v1, v2, size=1024 * 1024):
24
+ # check if the first 1MB (size) has the same hash between the two videos
25
+ print(f"{v1} & {v2} are duplicate.")
26
+ continue
27
+
28
+ # compare audio between the two video
29
+ similarity = acoustic_compare_two_videos(v1, v2)
30
+ if isinstance(similarity, str):
31
+ print(f"error: {similarity}")
32
+ continue
33
+
34
+ if similarity > 0.9:
35
+ print(f"{v1} & {v2} are duplicate.")
36
+ continue
37
+ elif similarity > 0.8:
38
+ print(f"{v1} & {v2} are likely duplicate.")
39
+ continue
40
+
41
+ if check_if_duplicate_videos_phash(v1, v2):
42
+ # best method but very long/computer intensive
43
+ print(f"{v1} & {v2} are duplicate.")
44
+
45
+ else:
46
+ print(f"{v1} & {v2} are NOT duplicate.")
47
+ ```
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "video-duplicate"
3
+ version = "1.0.0"
4
+ description = "Find duplicate videos across folders"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = [
8
+ "pillow>=12.3.0",
9
+ "pyacoustid>=1.3.1",
10
+ "videohash>=3.0.1",
11
+ ]
12
+
13
+ [[project.authors]]
14
+ name = "ritonun"
15
+ email = "you@example.com"
16
+
17
+ [project.scripts]
18
+ video-duplicate = "video_duplicate:main"
19
+
20
+ [build-system]
21
+ requires = ["uv_build>=0.12.19,<0.13.0"]
22
+ build-backend = "uv_build"
@@ -0,0 +1,15 @@
1
+ [project]
2
+ name = "video-duplicate"
3
+ version = "1.0.0"
4
+ description = "Find duplicate videos across folders"
5
+ readme = "README.md"
6
+ authors = [{ name = "ritonun", email = "you@example.com" }]
7
+ requires-python = ">=3.13"
8
+ dependencies = ["pillow>=12.3.0", "pyacoustid>=1.3.1", "videohash>=3.0.1"]
9
+
10
+ [project.scripts]
11
+ video-duplicate = "video_duplicate:main"
12
+
13
+ [build-system]
14
+ requires = ["uv_build>=0.12.19,<0.13.0"]
15
+ build-backend = "uv_build"
@@ -0,0 +1,8 @@
1
+ # __init__.py
2
+
3
+ from .audio import acoustic_compare_two_videos
4
+ from .file import load_probable_duplicate, load_videos_from_folder
5
+ from .hashing import (
6
+ check_if_duplicate_videos_phash,
7
+ check_if_video_duplicate_quick_hash,
8
+ )
@@ -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)
@@ -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
@@ -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())