Media Library Organizer
media-organizer recursively inspects one media directory and
creates a separate, organized copy. Its scan, plan, apply,
report, and duplicates subcommands never move, rename, delete, modify,
re-encode, or write metadata into source media. review-duplicates is the
only subcommand that deletes source media, one confirmed copy at a time; see
Duplicate detection and review. Input and output trees must not overlap.
Workflow
First inspect the library without writing a plan or copying media files:
media-organizer scan --input "/home/user/Media" --verbose
Then create a normalized SQLite manifest and a reviewable Markdown report. By
default they are saved as organization-plan.sqlite3 and
organization-plan.md inside the organized output directory:
media-organizer plan \
--input "/home/user/Media" \
--output "/home/user/Organized" \
--verbose
Review the report, then apply the exact approved SQLite plan:
media-organizer apply \
--plan "/home/user/Organized/organization-plan.sqlite3" \
--verbose
--plan-file selects the SQLite database path and --report-file
selects the Markdown path. The normalized organization_plan,
plan_entries, plan_entry_warnings, and plan_skipped tables
record absolute source and destination paths, original and generated filenames,
recording time and timezone, metadata provenance, GPS coordinates, resolved
country and locality, detailed place, media type, technical metadata, warnings,
source identity, SHA-256 digest, and operation status. Existing JSON plans can
still be applied for backward compatibility.
After every entry applies successfully, the SQLite plan and Markdown report are removed. If any entry fails, both remain in place, and the database and Markdown report retain the recorded operation statuses so the problem can be inspected and the plan retried.
Privacy of generated files
Plans, reports, and the metadata cache can contain personal file paths, filenames, timestamps, and location data. Plans and the cache can also retain GPS coordinates and detailed place information. Disabling network geocoding does not remove location metadata from these local files.
Keep these outputs private. Before sharing an example or bug report, replace personal paths and metadata with synthetic values. The repository ignores SQLite databases and their sidecar files, default Markdown plans, and legacy JSON plans and reports. Custom output filenames still need their own ignore rules or storage outside the checkout. Ignore rules do not remove data that has already been committed from Git history.
Library report
After organizing, run report inside the library to save a SQLite inventory
of what it holds:
cd "/home/user/Organized"
media-organizer report
The inventory is written to media-report.sqlite3 in the current directory
and replaces an older report there. --input selects a different directory;
the report is still saved in the current directory. report accepts the
same --overrides, --index, geocoding, and --verbose options as
scan, never copies media, and exits with status 1 when a file is skipped.
The normalized database contains:
media_report: report metadata plus total, photo, video, classified, unclassified, country, locality, and skipped counts.report_formats: file extensions such asJPGorMP4, counted separately for images and videos.report_orientations:landscape,portrait,square, andunknownfiles, counted separately for images and videos.report_locations: files per locality within each country.report_entries: each file’s path, media type, format, size in bytes, displayed resolution, orientation, duration, country, locality, and location provenance.report_skipped: files that could not be read, with the reason.
Orientation compares the displayed width and height. Video rotation comes from
the FFprobe display matrix. Photo rotation comes from the EXIF orientation that
ExifTool reads, so without ExifTool a rotated photo keeps its stored
orientation. FFprobe 7 and later list each tile of a tiled HEIF photo as a
separate stream; such a photo takes its size from ExifTool and is unknown
without it.
A file whose path follows the organizer’s Year/Country/Locality layout, and
whose generated name repeats that year and locality, reports that folder’s
country and locality with organized:path provenance. Other files are
classified the same way scan classifies them. When OpenStreetMap
Nominatim resolved a location, the report includes its attribution.
Duplicate detection and review
duplicates finds exact visual duplicates, such as the same photo or video
copied from several devices, and saves them for review in the browser:
media-organizer duplicates --input "/home/user/Media" --verbose
media-organizer review-duplicates --report duplicate-report.sqlite3
duplicates requires ffmpeg and ffprobe on PATH and otherwise
fails before writing anything. It only reads media and writes
duplicate-report.sqlite3 to the current directory, replacing an older
report there; --report-file selects another path, which must not use a
media file extension. Every candidate that discovery finds is considered.
Candidates that resolve outside the input directory are skipped, and hard
links and symlinks to one file count once. Only files of equal size are
decoded and compared.
FFmpeg decodes every visual stream of a photo, or the first visual stream of a
video, to rgba64le frames without automatic rotation, and its
framehash muxer hashes each frame with SHA-256. The visual digest covers
the displayed size, the rotation, each stream’s dimensions and sample aspect
ratio, and every frame’s stream index, size, and hash in order, but not
timestamps or encoder details. Files are grouped only when these digests are
identical, so resized, cropped, recompressed, or merely similar media are not
duplicates. Decoding complete videos is CPU-intensive. Whether HEIC, HEIF, and
AVIF photos decode depends on the FFmpeg build. When FFmpeg cannot decode a
HEIC or HEIF photo, the organizer uses heif-convert (if installed) to make
a temporary lossless PNG for comparison and review previews. A HEIF file with
multiple images is skipped by this fallback. Without ExifTool, photos
that differ only in their EXIF orientation tag can match. A candidate that
cannot be read, validated, or decoded is recorded as skipped without stopping
other files, and the command then exits with status 1. The normalized
database contains:
duplicate_report: the schema version, creation time, and source directory.duplicate_groups: each group’s file size, visual digest, andunresolvedorresolvedstatus.duplicate_entries: each copy’s path, media type, device, inode, nanosecond modification time, byte SHA-256 digest, displayed resolution, duration,presentordeletedstatus, and last deletion error.duplicate_skipped: files that could not be compared, with the reason.
review-duplicates binds only to 127.0.0.1, on port 8765 unless
--port selects another, and opens a tokenized page in the system browser.
The page is available only while the command runs in the foreground; Ctrl+C
stops the server and closes the report, and a busy port makes the command exit
with status 1. Each unresolved group shows its present copies side by side
with a preview, path, media type, size, resolution, and duration, and every
copy has its own delete button, so a group of three or more copies is reviewed
as one group.
Warning
Deleting a copy permanently removes that file from disk and cannot be undone. The report’s status records are an audit trail, not a backup.
A deletion needs an explicit browser confirmation and a POST request carrying
the page’s per-process token; GET requests never delete anything. The server
then takes the report’s write lock, reloads the entry, and requires that it is
still present in an unresolved group, that its path still resolves inside the
recorded source directory, that it is a regular file rather than a symlink,
that its device, inode, size, and nanosecond modification time are unchanged,
that another copy in the group still matches the report, and that its byte
SHA-256 digest still matches. Before touching the file, it writes the outcome
to the report: the copy is marked deleted, and its group becomes
resolved once fewer than two present copies remain. It then repeats the
identity check, removes exactly that path, and commits the report only after
the removal succeeds. A failed check deletes nothing and records a short
reason on the copy instead, which the page then shows, and repeating a
submission for a deleted copy or a resolved group changes nothing.
Nothing is deleted when the report cannot be updated, for example because it is read-only or locked by another process; the request then fails with a fixed error that contains no file details. Removing a file and committing the report cannot be one atomic step, so if only the final commit fails, the removed copy stays listed as present and then fails revalidation as missing.
Previews are generated on request, and only for report entries that still match their recorded identity. A photo preview is one frame scaled to fit 640x640 pixels, and a video preview is a 3x3 contact sheet of key frames spread over its duration, each tile fitting 320x320 pixels. FFmpeg gets 30 seconds per preview; a failed, slow, or changed file shows a “Preview unavailable” image instead. Every response carries a restrictive Content Security Policy and anti-framing, no-sniff, no-referrer, and no-store headers, error responses contain no filesystem details, and every rendered path is HTML-escaped.
Progress reporting
--verbose is available on scan, plan, apply, report, and
duplicates. It flushes messages immediately so progress remains visible
during long operations. Scan and report show recursive discovery, cache hits,
metadata extraction, classification, and skipped files. Plan reports
destination selection and hashing percentages. Duplicates shows discovery,
equal-size comparison, metadata extraction, frame decoding, byte hashing
percentages, and skipped files.
Apply reports source validation, copying and verification percentages, each
saved operation status, failures, and final plan cleanup. Without the option,
the concise command output is unchanged.
Discovery and metadata
Recognized image extensions are JPEG, JPG, PNG, HEIC, HEIF, WebP, TIFF, TIF, and AVIF. Recognized video extensions are MP4, MOV, MKV, AVI, WebM, M4V, WMV, MPEG, MPG, MTS, M2TS, and 3GP. Matching is case-insensitive. An extension only makes a file a candidate: FFprobe normally must confirm that it contains a decodable visual stream. When FFprobe cannot parse a HEIC or HEIF image, ExifTool may validate its file type and positive dimensions instead. Format availability can depend on the installed FFmpeg and ExifTool builds. Unreadable and invalid candidates are reported and do not stop unrelated files.
FFprobe is required on PATH. ExifTool is optional and enriches recording
timestamps, EXIF GPS, title, dimensions, and camera/device metadata for photos
and videos when installed. The organizer deliberately uses each source file’s
filesystem modification time as the effective recording time for year grouping
and generated filenames. Its
local UTC offset is retained and the manifest marks its provenance as
filesystem:mtime. The nanosecond modification time is also used for cache
invalidation and approved-plan validation. Missing locations remain unknown.
Location classification
Classification is conservative and considers, in order, embedded GPS, explicit geographic metadata, recognizable aliases in the title, filename or source path, and per-file overrides. A result must contain both a country and a parent city, town, or village. Detailed landmarks remain in the manifest but do not become directories.
GPS coordinates are never transmitted by default. To approve sending them to the OpenStreetMap Nominatim service for reverse geocoding, opt in explicitly:
media-organizer plan \
--input "/home/user/Media" \
--output "/home/user/Organized" \
--allow-network-geocoding
Only coordinates are used in the lookup; media contents are never uploaded.
Exact lookup results are cached, requests are single-threaded and limited to
one per second, and output includes OpenStreetMap attribution. The public
server discourages large or recurring bulk jobs; read the Nominatim usage
policy before
opting in. --geocoder-url can select a compatible self-hosted or alternate
service; it must be an http or https URL. Service availability and returned administrative boundaries remain
external limitations, so review the plan before applying it.
Overrides
An optional UTF-8 JSON file can define recognizable place aliases and exact
per-file classifications. Relative file keys use paths below --input:
{
"places": {
"Beograd": {"country": "Serbia", "locality": "Belgrade"},
"Lloret": {"country": "Spain", "locality": "Lloret de Mar"}
},
"files": {
"2026/unknown/IMG_1031.mov": {
"country": "Serbia",
"locality": "Novi Sad"
}
}
}
Pass it to either inspection command with --overrides overrides.json.
Conflicting recognizable aliases cause an unclassified result instead of a
guess. The organizer never merges nearby coordinates with a radius rule.
Output names and unknown metadata
Classified media files are grouped as Year/Country/Locality and named
YYYY-MM-DD_HH-MM-SS_Locality.ext. Multiple visits to the same locality in
one year therefore share a directory. Country and locality components are
transliterated to ASCII Latin letters and digits with hyphen separators;
Cyrillic and Greek names have explicit transliteration support, and Latin
diacritics are folded to their ASCII forms. Known localized names are
canonicalized to English first, such as España to Spain, Србија to
Serbia, and Београд to Belgrade. Online reverse-geocoding requests
also explicitly request English results. A name in an unsupported script
uses Unknown-Country or Unknown-Locality rather than placing non-ASCII
text in a path. Deterministic _002, _003, and later suffixes resolve
collisions.
If none of the media files has a classified location, the organizer
omits location directories. Dated files then go directly under Year and
use their sanitized containing-folder description as the filename title. For
example, 2022-08 - Traganou Beach on Rhodes produces
Year/YYYY-MM-DD_HH-MM-SS_Traganou_Beach_on_Rhodes.ext. The leading
YYYY-MM - `` portion is omitted, and description words are joined with
underscores. If at least one file is classified, a known year with an unknown
location still goes to
``Year/Unclassified and is named
YYYY-MM-DD_HH-MM-SS_Unclassified.ext. Normal source files always have a
filesystem modification time; Unclassified/Unknown-Year and
Undated_<content-hash>.ext remain defensive fallbacks for manually created
metadata without one. Organized filenames never reuse the original filename,
although the original name remains recorded in both manifests. The tool never
fabricates a location.
Cache and copy safety
The default SQLite index is
$XDG_CACHE_HOME/media-organizer/metadata.sqlite3 or, when that variable is
unset, ~/.cache/media-organizer/metadata.sqlite3. --index selects a
different path, but the index must remain outside the source tree. Cached
metadata is reused only while absolute path, size, nanosecond modification
time, device, and inode still match. Records cached before display rotation
was recorded are read again once.
Planning hashes every source file. Applying a plan checks its recorded size, timestamp, and SHA-256 before copying. It checks available space, writes a temporary file beside the destination, verifies its size and digest, and finalizes without replacing an existing path. An identical destination is reported as already organized; a different existing file is left untouched. The SQLite manifest status is transactionally updated after each entry, interrupted temporary files are removed, failures do not stop unrelated entries, and reapplying a completed plan is safe.