A CLI tool that organizes photos and videos into a dated folder structure, detects duplicates, and keeps originals safe. I built it to organize my own personal photos, and organized more than 100k photos with this tool so far. If you encounter any problems open an issue and I will try to fix it quickly.
This tool moves and modifies photos/videos. Use at your own risk — no warranty is provided. Always run with dry_run: true first and keep backups of irreplaceable photos.
# 1. Install dependencies
pip install -r requirements.txt
# 2. Create a config file (see config.example.json for all options)
# 3. Always do a dry run first — nothing is moved, just previewed
python main.py config.json
# 4. Run for real
python main.py -y config.json-y skips the confirmation prompt. dry_run defaults to true — change it to false in config when ready.
target/
├── 2024/
│ ├── 04-April/
│ │ ├── photo.jpg ← files land here by default
│ │ └── 14/ ← day subfolder only when ≥ 40 photos on that day
│ │ └── event.jpg
│ └── 05-May/
│ └── video.mp4
├── duplicates/ ← duplicate files (never deleted, just moved aside)
├── already_exist/ ← files already in target
├── corrupted/ ← unreadable files
└── shortcuts/ ← flat symlink/hardlink directory (if enabled)
Core (required):
pip install -r requirements.txt # Pillow, pillow-heif, tqdmOptional GPU acceleration (install only what matches your hardware):
pip install cupy-cuda12x # NVIDIA (or cupy-cuda11x for older CUDA)
pip install openvino # Intel
pip install coremltools # Apple SiliconOptional system tool for video date extraction:
# Install ffmpeg (provides ffprobe) — without it, videos use filename/filesystem dates
sudo apt install ffmpeg # Linux
brew install ffmpeg # macOSAll settings live in a JSON file. See config.example.json for a complete reference with all options and their defaults.
| Field | Required | Description |
|---|---|---|
input |
Yes | Array of source directories to scan |
target |
Yes | Destination directory for organized files |
input_ignored |
No | Subdirectories to skip during scan |
input_additional_duplicates_check |
No | Extra dirs to check for duplicates (not organized, just compared) |
target_duplicates |
No | Where duplicates go (default: target/duplicates) |
target_already_exist |
No | Files already in target (default: target/already_exist) |
target_corrupted |
No | Unreadable files (default: target/corrupted) |
target_shortcuts |
No | Flat shortcut directory (default: target/shortcuts) |
log_directory |
No | Log and report output (default: ./logs) |
| Option | Type | Default | Description |
|---|---|---|---|
dry_run |
bool | true |
Preview only — no files are moved or copied |
move_not_copy |
bool | true |
Move files instead of copying them |
organizing_enabled |
bool | true |
Enable date-based folder organization |
duplicates_enabled |
bool | true |
Enable duplicate detection and relocation |
duplicate_strategy |
string | "hash" |
"hash" (content, most accurate), "filename", or "skip" |
shortcuts_enabled |
bool | false |
Create a flat symlink/hardlink directory alongside organized files |
shortcut_type |
string | "symlink" |
"symlink" or "hardlink" |
rename_enabled |
bool | false |
Rename files to img_YYYY_MM_DD_HH_MM_SS_... / vid_... format |
date_source |
string | "modified" |
Filesystem date fallback: "modified", "created", "accessed", "oldest" |
date_priority |
string | "filesystem_only" |
"exif_first", "exif_only", "filesystem_first", "filesystem_only" |
set_modified_to_capture_date |
bool | true |
Stamp each organized file's mtime with its computed capture date |
pictures_folder_per_day |
int | 40 |
Minimum photos on a single day to create a DD/ subfolder |
gpu_enabled |
bool | true |
Enable GPU acceleration for hashing and date extraction |
gpu_method |
string | "auto" |
"auto", "nvidia", "intel", "mac_arm64", or "disabled" |
multiprocessing_level |
int | 100 |
CPU usage: 0 (single-threaded), 25, 50, 75, or 100 (all cores) |
multiprocessing_gpu_level |
int | 100 |
GPU batch size multiplier: 0, 25, 50, 75, or 100 |
hash_cache_enabled |
bool | true |
Cache file hashes in SQLite to speed up repeat runs |
hash_cache_path |
string | "./cache/hash_cache.db" |
Path to the hash cache database |
force_rehash |
bool | false |
Ignore cached hashes and recalculate everything |
corruption_check_enabled |
bool | false |
Check each file is readable before processing |
ignore_subdirectories |
bool | false |
Only scan top-level files in input directories |
ignore_dotfiles |
bool | true |
Skip hidden files (names starting with .) |
separate_dotfiles |
bool | true |
Move dotfiles to a separate folder instead of skipping |
ignore_thumbnail |
bool | false |
Skip files that look like thumbnails |
ignore_thumbnail_less_than_size |
int | 100 |
Thumbnail size threshold in KB |
ignore_thumbnail_name_contain |
string | "thumb" |
Filename pattern identifying thumbnails |
verbose_logging |
bool | false |
Print detailed per-file output to console |
matchModifiedToCreated |
bool | false |
Set mtime = creation time for each file (mutually exclusive with the option below) |
matchCreatedToModified |
bool | false |
Set creation time = mtime for each file |
date_source: "oldest" + date_priority: "exif_first" is the safest combination: it prefers the embedded capture date and, when falling back to filesystem, takes the older of modified vs. birth time — self-correcting against dates pushed forward by copies or backups.
Images: JPG, JPEG, PNG, GIF, BMP, TIFF, TIF, HEIC, HEIF, WEBP
Videos: MP4, MOV, AVI, MKV, WMV, M4V, 3GP, FLV, MTS, M2TS
repair_dates.py repairs files whose filesystem dates were pushed forward. For each media file it determines the true capture date (EXIF → ffprobe video metadata → filename pattern) and, if the file's current date is newer than the true date, resets mtime/atime to the true date.
python repair_dates.py /path/to/library # dry run — shows what would change
python repair_dates.py /path/to/library --apply # apply the fixes
python repair_dates.py /path/to/library --apply --verbose #Verbose output
python repair_dates.py /path/to/library --apply --rename # Just rename the files to img/vid_date_formatNote: On Linux, userspace cannot set a file's true birth/creation time. Only mtime and atime can be reset — but mtime is what the organizer reads when filing, so this is sufficient.
- Always start with
"dry_run": trueand verify where files would land before running for real. - Enable
hash_cache_enabledfor large libraries — subsequent runs are dramatically faster. - Use
multiprocessing_level: 0+verbose_logging: truefor debugging.
Contributions are welcome. One hard rule: do not change the duplicate-detection logic (get_file_hash, detect_duplicates_optimized, build_target_file_hashes, and the related handlers in main.py) without extensive testing. That code went through a long cycle of manual review and rework to reach its current state — a seemingly small change can silently break deduplication on real libraries where mistakes are not easily undone.
This project is licensed under the Mozilla Public License 2.0 — see the LICENSE file for details. Copyright (c) 2026 aalzubidy.