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 as JPG or MP4, counted separately for images and videos.

  • report_orientations: landscape, portrait, square, and unknown files, 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, and unresolved or resolved status.

  • duplicate_entries: each copy’s path, media type, device, inode, nanosecond modification time, byte SHA-256 digest, displayed resolution, duration, present or deleted status, 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.