media_organizer 1.20260615.1205131
media_organizer: ^1.20260615.1205131 copied to clipboard
Normalize photos and videos into self-describing, sortable, dedup-friendly filenames (datetime · place · size · checksum). CLI + library, cross-platform.
media_organizer #
Normalize photos and videos into self-describing, sortable, dedup-friendly filenames — then they're easy to manage anywhere (local, Google Drive, NAS).
<YYYYMMDDTHHmmss>[_<place>]_<sizeBytes>_<md5-8>.<ext>
20260615T091500_Taipei_1048576_9f3ab2c1.jpg
- Sortable — sort by name = sort by capture time.
- Self-describing — capture time + optional place are in the name.
- Dedup-friendly — the trailing
<size>_<md5-8>is a content key; identical bytes ⇒ identical key, so duplicates are detected without a database.
Ships as a CLI and a library, pure dart:io — runs on desktop and mobile
(import the library on tablets/phones; not web).
CLI #
dart pub global activate media_organizer
# Organize: rename + dedup into an output folder
media_organizer organize -i ~/Camera -o ~/Organized
media_organizer organize -i ~/Camera -o ~/Organized --move # move, not copy
media_organizer organize -i ~/Camera -o ~/Organized --dry-run # preview only
media_organizer organize -i ~/Camera -o ~/Organized --verify # re-hash each copy
# --move always verifies first: a copy is re-hashed and only on a match is the
# original deleted, so a bad copy never loses an original. Ctrl-C stops
# cleanly after the current file; a live [i/total] line shows progress.
media_organizer organize -i ~/Camera -o ~/Organized --transcode # re-encode videos
# --transcode re-encodes videos to 1080p H.264 + faststart via ffmpeg.
# Needs ffmpeg on PATH (or --ffmpeg <path>); if missing it prints install
# instructions and exits without touching your files.
# Stats: total + per-month photo/video counts (no database — reads the names)
media_organizer stats ~/Organized
# Total: 120 (95 photos, 25 videos)
# 2026.01 12 photos 3 videos
# 2026.02 8 photos 1 videos
In this repo you can run it without installing:
dart run bin/media_organizer.dart organize -i <in> -o <out>
dart run bin/media_organizer.dart stats <folder>
dart run tool/demo.dart # organizes the bundled example/demo_input
statsneeds no database — the filename is the index (capture time + kind are in the name), so it works on any folder of normalized files.
Library #
import 'package:media_organizer/media_organizer.dart';
final report = await MediaOrganizer().run(
input: '/camera',
output: '/organized',
verify: true, // re-hash each copy (forced on when move: true)
onProgress: (p) => print('[${p.index}/${p.total}] ${p.path}'),
cancelled: () => false, // return true to stop the run cleanly
);
print(report); // organized / duplicates / failed
Pluggable (cross-platform by design) #
The engine is platform-independent; the platform-specific or networked parts are injectable interfaces with safe defaults:
MediaProbe— capture time + GPS. DefaultCompositeMediaProberoutes by kind:ExifMediaProbe(image EXIF) for photos,Mp4MediaProbe(parses the MP4/MOVmoovbox — Applecreationdate,mvhdtime, ISO-6709 GPS) for videos, then falls back to the file's modified time when neither has a date.LocationResolver— GPS → place label for the filename. Default: none (no network). Ships withOfflineGeocoder(haversine nearest-neighbour, fully offline, no API key) — see Offline geocoding.MediaTranscoder— optional transcode before output. DefaultPassthroughTranscoder(no transcode). A ready-madeFfmpegTranscoderre-encodes videos to 1080p H.264 + faststart (FfmpegTranscoder.isAvailable()/FfmpegTranscoder.installHint()let you detect ffmpeg and guide the user); it shells out to ffmpeg and reads/writes real files, so use it withLocalFileSystem.MediaFileSystem— all file IO (list/read/copy/delete) goes through this. DefaultLocalFileSystemusesdart:io(desktop + mobile). Inject your own for an alternate source or fully in-memory tests — the engine itself never touchesdart:io. (dart:iocovers every native platform; only web lacks it, and a folder organizer can't run in a browser anyway.)
MediaOrganizer(
probe: MyVideoAwareProbe(),
locationResolver: MyGeocoder(),
transcoder: FfmpegTranscoder(),
);
Upload (Google Drive / NAS) #
NAS needs no code — it's a mount, so just organize straight into it:
media_organizer organize -i ~/Camera -o /Volumes/nas/Photos
For cloud targets, UploadTarget is a tiny injectable sink that consumes an
organize run. The core stays SDK-free — implement the target in your app (or a
side package) with whatever client you like:
final report = await MediaOrganizer().run(input: '/camera', output: '/staged');
final upload = await MediaUploader(target: MyDriveTarget()).run(report.organized);
print(upload); // uploaded / skipped / failed
The same content key that dedups locally also dedups remotely: a target
reports the keys it already has via existingContentKeys(), so re-runs upload
only what's new (and identical bytes within one run upload once). See
test/upload_target_test.dart for the FakeUploadTarget skeleton a real
Drive/S3/SFTP target follows.
Offline geocoding #
OfflineGeocoder turns the GPS in a photo into the nearest place name for the
<place> slot — fully offline, no API key. The core ships no geo data: you
pass the place list, so the package never bundles (or assumes a format for) a
dataset.
final geocoder = OfflineGeocoder(const [
GeoPlace('Taipei', 25.0330, 121.5654),
GeoPlace('Kaohsiung', 22.6273, 120.3014),
// …
], maxDistanceKm: 50); // farther than this ⇒ no place
await MediaOrganizer(locationResolver: geocoder).run(input: '/camera', output: '/out');
// 20260115T083000_Taipei_2187_de2c09bb.jpg
For real coverage, load a dataset into List<GeoPlace> and localize names by
choosing what you put in GeoPlace.name:
example/offline_geocoder_example.dart— inline cities + aparseGeoNamesCitieshelper for GeoNames. Get the data from download.geonames.org: thecitiesN.zipfiles list places with population > N. For reverse-geocoding prefer the densecities500(~200k) orcities1000(~140k);cities5000/cities15000are major-cities-only (coarse for reverse, fine for a picker).example/immich_geodata_example.dart— reads the already zh-TW localized CSVs from immich-geodata-zh-TW (latitude,longitude,country,admin_1..4); a Taipei 101 photo comes out as…_臺北市-信義區_…. Fully offline — you supply the downloaded CSV path.
The lookup is a linear scan (fine for thousands of points; a country file like
tw is ~8k); for a full global set, wrap it with a spatial index in your app.
Which geocoding strategy? #
LocationResolver is just an interface — pick the backing that fits your
context (they all inject the same way, no core change):
| Strategy | Best for | Trade-offs |
|---|---|---|
Offline dataset (OfflineGeocoder + GeoNames / immich-geodata) |
CLI / batch over thousands of files | Reproducible, no rate limits, works offline. You ship/download the data; names only as precise as the dataset. |
Platform geocoder (iOS/macOS CLGeocoder, Android Geocoder, via the geocoding plugin) |
Interactive Flutter app, modest volume | Best names, auto-localized, no data to ship. But it's an online call, rate-limited (bad for big batches), and Apple/Android-only — so it lives in your app, not this pure-Dart core. |
| Offline-first hybrid | Want quality and scale | Resolve offline first; only fall back to the platform geocoder on a miss, and cache results (rounded coords → name) so a trip's worth of nearby photos doesn't hammer the API. |
// Hybrid sketch (lives in your app — the platform geocoder is not pure Dart):
class HybridGeocoder implements LocationResolver {
HybridGeocoder(this.offline, this.platform);
final LocationResolver offline;
final LocationResolver platform; // e.g. backed by package:geocoding
final _cache = <String, String?>{};
@override
Future<String?> resolve(double lat, double lng) async {
final key = '${lat.toStringAsFixed(2)},${lng.toStringAsFixed(2)}';
return _cache[key] ??=
await offline.resolve(lat, lng) ?? await platform.resolve(lat, lng);
}
}
Geo-data licensing #
This package bundles no geo data — you supply it — so nothing here is encumbered. But the datasets the examples point at carry their own licenses, and complying is the responsibility of whoever ships them:
- GeoNames — CC BY 4.0: free (incl. commercial), attribution required.
- SimpleMaps (free tier) — CC BY 4.0: attribution required.
- OpenStreetMap (and OSM-derived data, e.g. the immich-geodata sets) — ODbL 1.0: attribution (“© OpenStreetMap contributors”) and share-alike on a derived database you distribute.
- Natural Earth — public domain; Wikidata — CC0 (public domain).
Note: the immich-geodata-zh-TW project code is GPL v3, but this package only reads its data output (governed by the data licenses above) — it does not include or link that code, so your MIT package stays MIT.
Filename helper #
MediaFilename.build(...) / MediaFilename.parse(name) give you the scheme on
its own (e.g. to sort or group an existing library).
Status & limits #
- Capture time: image EXIF for photos, MP4/MOV
moovfor videos, with a file-mtime fallback. Containers without embedded time (e.g. some camera/transcoded clips) use mtime.mvhdtime is treated as UTC; an Applecreationdate(with a real timezone) is preferred when present. - Geocoding is not built in — it's an injectable interface (no network by
default). Transcoding is opt-in via
FfmpegTranscoder(needs ffmpeg installed); the core itself stays ffmpeg-free. Upload targets (Google Drive / NAS) are out of scope for the core and layered on top. - No web (uses
dart:io).
License #
MIT