Move your phone’s photos/videos from a desktop staging area into a month-span Google Drive folder, with:
- file renaming (in-process, by EXIF/filename datetime),
- duplicate-aware moves (no more
_1junk), - conflict quarantine,
- junk sweeping (
.trashed*,.thumbnails,Contents.csv,desktop.ini), - clean logs,
- progress bars.
Manual shuffles suck. This script codifies the flow you already do:
- Drop
DCIM,Download/Downloads,Movies,Picturesfrom your phone into a staging folder. - Rename the camera files by EXIF datetime.
- Verify the files exist in Dropbox Camera Uploads.
- Move everything into a Google Drive month folder named like
202509_202510.
Renaming and verification used to live in two separate sibling repos
(rename-images-to-datetime, files-in-folder) that this project shelled out to.
That logic now lives here as rename_images.py and organize_files.py, callable
directly or as standalone CLI subcommands -- no more juggling three repos/envs.
The backup pipeline is 7 steps, moving files through a few intermediate directories
before they land in the destination month-span folder. Every hop is dedupe-aware:
identical content is skipped and the source deleted; same-name-but-different content
is quarantined to _conflicts/ instead of overwriting anything.
flowchart LR
subgraph Staging["staging_root (drop phone exports here)"]
DCIM_CAM["DCIM/Camera"]
DCIM_OTHER["DCIM/other subfolders"]
DL["Download(s)"]
PIC["Pictures"]
MOV["Movies"]
end
RENAME["rename_tool_input"]
DESKTOP["desktop_mobile_camera (local mirror)"]
DROPBOX["dropbox_camera_uploads"]
subgraph Dest["google_mobile_base / dest span folder"]
DEST_CAM["Camera"]
DEST_PIC["Pictures"]
DEST_MOV["Movies"]
end
DCIM_CAM -->|"Step 1: dedupe move"| RENAME
RENAME -->|"Step 2: rename by EXIF / filename datetime"| RENAME
RENAME -->|"Step 3: dedupe move"| DESKTOP
DESKTOP -->|"Step 4: verify, copy missing"| DROPBOX
DROPBOX -->|"Step 5: dedupe move"| DEST_CAM
DCIM_OTHER -->|"Step 6: catch-all"| DEST_PIC
DL -->|"Step 6: catch-all"| DEST_PIC
PIC -->|"Step 6: catch-all"| DEST_PIC
MOV -->|"Step 7: dedupe move"| DEST_MOV
- Dedupes by content: if the same file already exists at the destination → skip and delete the source copy.
- Merges folders: no blind renames—existing folder wins; new content merges in.
- Conflicts: if names collide but bytes differ → file goes to
_conflicts/. - Sweeps junk: deletes
.trashed*,.thumbnails,Contents.csv,desktop.inialong the way. - Dry-run first: prints exactly what would happen, no changes.
- Logs: writes a log file so you can see the receipts later.
- Python 3.10+
- Poetry (https://python-poetry.org/)
- (Optional)
tqdmfor pretty progress bars.
WSL users: Windows paths like C:\Users\... become /mnt/c/Users/....
poetry install
poetry add tqdm
This repo does not track your real config. Copy the sample and fill in your own paths (no personal info in the repo).
Command: cp config.example.yaml config.yaml
config.example.yaml (safe template):
Copy to config.yaml and replace all /path/to/... with absolute paths for YOUR machine.
where you drop DCIM, Download/Downloads, Movies, Pictures from the phone
staging_root: /path/to/Desktop/mobile
rename_tool_input: /path/to/mobile-backup/input
dropbox_camera_uploads: /path/to/Dropbox/Camera Uploads
google_mobile_base: /path/to/Google Drive/Multimedia/Pictures/Personal/Mobile
destination_span_mode:
prev_curr_month(default) -- auto{prev}_{curr}calendar month, e.g.202509_202510file_date_range-- derive the span from the actual dates of the files being processed. Use this for backfilling a batch of staged files that spans more than one month; a singlerunwill land everything in one{min-month}_{max-month}folder instead of mislabeling old files under today's month.override-- usedestination_span_overrideverbatim
destination_span_mode: prev_curr_month
destination_span_override: null # used only when mode is "override"
destination_span_date_source: filename # filename|mtime|exif -- tried first, then the other two as fallback
destination_span_on_parse_failure: fallback_prev_curr # fail|fallback_prev_currdestination_span_date_source only matters in file_date_range mode: it's the first source tried per file; if that fails, the other two are tried in a fixed order (filename, mtime, exif) before giving up on that file. destination_span_on_parse_failure only matters if more than half the staged files can't be dated by any source -- fail raises, fallback_prev_curr falls back to the auto month-span behavior for the whole run.
desktop_mobile_camera: /path/to/Desktop/mobile/DCIM/Camera
verbosity: 0 # 0=quiet, 1=notes (includes deleted-file details in real run), 2=debug
Dry-run is controlled by the CLI, not config.yaml: every subcommand previews
by default and only makes real changes when you pass run as the mode
argument (see Usage below).
mobile_backup.py is a small CLI with four subcommands. backup, rename, and
organize each take an optional dry/run mode argument (default dry) --
matching every other ToolShed tool: nothing writes to disk until you
explicitly pass run. They also all accept --config PATH to use a config
file other than config.yaml, which is what makes the playground below
possible without ever touching your real config.
backup [dry|run]-- the full pipeline (steps 1-7 below)rename [dry|run]-- standalone: rename images inrename_tool_inputby EXIF/filename datetimeorganize [dry|run]-- standalone: verifydesktop_mobile_camerafiles exist indropbox_camera_uploads, copying over anything missingplayground-- generate synthetic source data and a scratch config, for a look-and-see rehearsal before touching real files (see below)
Never run this against real data cold. Generate a synthetic source tree and a matching scratch config, entirely self-contained under a throwaway directory:
poetry run python mobile_backup.py playgroundThis creates ./playground/ with a realistic staging/ folder (camera files
spanning a few different months, other album/Movies/Downloads folders, and some
junk to be swept) plus a ready-to-use playground/config.yaml
(destination_span_mode: file_date_range). Look through playground/staging/ --
that's the "source." Then:
poetry run python mobile_backup.py backup --config playground/config.yamlRead the printed log, then run again with the run mode arg -- now look through
playground/target/ to see exactly where everything landed:
poetry run python mobile_backup.py backup --config playground/config.yaml runNothing here ever touches your real config.yaml or real directories; the
whole thing is disposable (rm -rf playground/ when you're done, or --force to
regenerate it).
Run: poetry run python mobile_backup.py backup (equivalent to ... backup dry)
Expected output (example):
Destination span: 202509_202510 (auto)
Destination: /path/to/.../Mobile/202509_202510
Step 5: would delete 14 unwanted; would move 3438 files (skipped 98 dupes, conflicts 2) from /path/to/.../Camera Uploads -> .../Camera
Done. (dry run)
- No changes are made.
- A log file is written:
- Dry run → in
google_mobile_base/(won’t create the month folder) - Real run → in the month folder (e.g., .../202509_202510/mobile_backup_*.log)
- Dry run → in
Run: poetry run python mobile_backup.py backup run
You’ll get progress bars and a full log inside the month folder. Same
dry|run mode argument applies to rename and organize, e.g.
poetry run python mobile_backup.py rename run.
Preview (no changes): poetry run python cleanup_folder.py 202509_202510
Apply (fix *_1 files/dirs; purge junk): poetry run python cleanup_folder.py 202509_202510 --apply
What cleanup_folder.py does:
- Delete
.trashed*,.thumbnails,Contents.csv,desktop.ini - Fix
*_1files:- identical to base → delete
_1 - base missing → rename to base
- different → quarantine to
_conflicts/
- identical to base → delete
- Fix
*_1folders:- if base missing → rename to base
- if base exists → merge contents (dedupe identicals, quarantine conflicts), then remove the
_1dir
- Writes/appends
cleanup_log.txtin the month folder.
- We never overwrite existing files.
- If a destination file with the same name exists:
- Identical content → source is skipped and deleted.
- Different content → source is moved to
_conflicts/for manual review.
- Junk is deleted before moves (in dry-run we just count what would be deleted).
- The scripts print counts for:
- would delete / deleted
- moved
- skipped dupes
- conflicts
staging_root/
├─ DCIM/
│ ├─ Camera/
│ └─ <other DCIM subfolders>/
├─ Download/ or Downloads/
├─ Movies/
└─ Pictures/
You manually drop the phone’s exported folders here (via MTP, Android File Transfer, etc.). The script handles the rest.
- WSL paths: Windows paths must be
/mnt/c/...,/mnt/d/..., etc. - Progress bars: install tqdm with
poetry add tqdmfor pretty bars; otherwise you’ll see periodic “X/N files” lines. - Conflicts in
_conflicts/: legit name collisions with different content—review and file them where you want. - Accidentally created
_1junk earlier: run the cleaner withpoetry run python cleanup_folder.py 202509_202510 --apply - Logs location:
- Dry run →
google_mobile_base/ - Real run → month folder (e.g., .../202509_202510/mobile_backup_*.log)
- Dry run →
Install: poetry install --with dev
Run tests: poetry run pytest
Full gate (format check + lint + type + coverage): poetry run tox
Auto-format: poetry run tox -e format
Housekeeping:
- Keep
config.yamllocal (ignored by git). - Commit only
config.example.yaml.
MIT. See LICENSE.