feat: optional photos.preserve_originals_as_bak — hide originals of edited photos via .bak - #458
Conversation
There was a problem hiding this comment.
Pull request overview
Adds an opt-in Photos sync behavior to keep untouched originals of edited assets as a hidden-on-purpose sidecar (.original.bak) while letting the edited (“current view”) file remain visible to photo browsers.
Changes:
- Introduces
photos.preserve_originals_as_bakconfig accessor inconfig_parser. - Adds a module-level toggle +
.original.baksuffixing logic tophoto_path_utils.generate_photo_filename_with_metadata. - Applies the toggle at the start of a photos sync run and adds a dedicated test suite covering the new behavior.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 9 comments.
| File | Description |
|---|---|
src/config_parser.py |
Adds get_photos_preserve_originals_as_bak() config helper (default False). |
src/photo_path_utils.py |
Adds global toggle + logic to suffix edited-photo original downloads with .original.bak. |
src/sync_photos.py |
Sets the new toggle once per sync run based on config. |
tests/test_preserve_originals_as_bak.py |
Adds unit tests for config defaulting and filename suffix behavior (including failure-safe alt detection). |
|
@epheterson thanks for your pr! I just thinking, maybe instead of new "preserve_originals_as_bak" option, we can use already existing file_sizes? Something like this: What do you think? |
|
Hey @olfway, thanks though that currently downloads both still. Sticking with that structure, we could add original:hidden to define this behavior, or keep the flag or maybe this niche feature is only on the fork. For all of the PRs, very grateful for your foundation and happy to share and reshape. Some specific things for my setup won't always make sense to ship, no worries at all and for any let me know! |
…dited photos via .bak
New optional config: photos.preserve_originals_as_bak (default False).
When True AND photos.filters.file_sizes contains both 'original' and
'original_alt', edited photos land as TWO files on disk:
- IMG_1234.JPG — the edited "current view" (visible to
Plex / Photos.app / Synology Photos)
- IMG_1234.HEIC.original.bak — the untouched original (no app
recognises .bak as an image extension,
so it's hidden from photo browsers but
filesystem-recoverable for 'revert to
original' scenarios)
Unedited photos (no original_alt available for them on iCloud) are
unaffected — they get their normal single file per the chosen
filename_format.
Requires original_alt in file_sizes to be meaningful. Without it, the
edited 'current view' isn't downloaded and you'd just get hidden
originals with no visible counterpart. The toggle is harmless without
original_alt (just no effect), but the pairing is the intended use.
Motivation: users who download both versions of edited photos currently
see both files in their photo apps as duplicates. This lets them treat
the edited version as the canonical 'current view' while keeping the
untouched original as a hidden filesystem-level backup.
The toggle is opt-in and defaults False, so existing users see no
behaviour change.
Implementation:
- New config_parser.get_photos_preserve_originals_as_bak(config) → bool
- Module-level _PRESERVE_ORIGINALS_AS_BAK in photo_path_utils.py set
once per sync run via the new set_preserve_originals_as_bak() setter
(same pattern as set_default_filename_format from the filename_format
PR)
- generate_photo_filename_with_metadata appends .original.bak to the
filename when the toggle is on, file_size is 'original', AND the
photo has an original_alt version on iCloud
- Soft check: exceptions reading photo.versions are treated as "no alt"
so a partial CloudKit record cannot break the filename pipeline
Naming convention: .original.bak qualifier is greppable
(find . -name "*.original.bak") and self-documenting in casual
filesystem browsing.
Tests: 10 in tests/test_preserve_originals_as_bak.py covering
- Config helper defaults / normalisation
- Edited photo's 'original' gets the suffix
- Edited photo's 'original_alt' does NOT get the suffix
- Unedited photo's 'original' does NOT get the suffix
- Non-'original' sizes (medium, thumb, etc.) do not get the suffix
- Suffix not applied when toggle is off
- photo.versions exception is non-fatal
Upstream CI runs ruff; auto-fixed COM812 (trailing comma) hits. No semantic change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…k test pollution + docstring Three Copilot review items, all real: 1. **Visibility invariant.** ``set_preserve_originals_as_bak()`` was enabled purely from ``photos.preserve_originals_as_bak``, but the suffix logic only renames the ``original`` file -- so if the user ISN'T also downloading ``original_alt`` (the edited "current view"), the only thing on disk is the hidden ``.bak`` file and the photo "disappears" from Plex / Photos.app / Synology Photos. Gate now requires BOTH ``preserve_originals_as_bak: true`` AND ``original_alt`` in ``filters.file_sizes``. The invariant is "every photo must have at least one visible representation on disk." The remove_obsolete sweep already cascades correctly when iCloud deletes a photo (neither the visible file nor the .bak is in tracked_files, both get unlinked) -- so there's no risk of orphan .bak files outliving their parent. 2. **MagicMock class pollution.** ``test_versions_raises_falls_through_to_normal_name`` was doing ``type(photo).versions = property(...)`` which mutates the global ``MagicMock`` class, leaking ``versions`` as a raising property into every other test that touches ``MagicMock().versions``. Replaced with a dedicated ``_BrokenPhoto`` stub class. 3. **Misleading docstring** on ``generate_photo_filename_with_metadata``. Old text claimed dual-mode support; the function only handles the metadata-style filename. Corrected. Added 3 new tests in TestSyncGateRequiresOriginalAlt that verify the gate logic at the sync_photos.sync_photos orchestrator boundary (toggle FALSE when original_alt absent; TRUE only when both gate conditions met). Verified on python:3.10 docker mirroring CI: ruff clean, 448 passed, 100.00% coverage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
e8227a5 to
621f765
Compare
|
/opencode please review this code. |
|
Here is my review of this PR: Review:
|

Summary
New optional config:
photos.preserve_originals_as_bak(defaultFalse).When
TrueANDphotos.filters.file_sizescontains bothoriginalandoriginal_alt, edited photos land as TWO files on disk:IMG_1234.JPG— the edited "current view" (visible toPlex / Photos.app / Synology Photos)
IMG_1234.HEIC.original.bak— the untouched original (no apprecognises
.bakas an imageextension, so it's hidden from photo
browsers but filesystem-recoverable
for "revert to original" scenarios)
Unedited photos (no
original_altavailable for them on iCloud) areunaffected — they get their normal single file per the chosen
filename_format.Motivation
Users who download both versions of edited photos currently see both files
in their photo apps (duplicates). This option lets them treat the edited
version as the canonical "current view" while keeping the untouched
original as a hidden filesystem-level backup.
The toggle is opt-in and defaults False, so existing mandarons users see
no behaviour change.
Implementation
config_parser.get_photos_preserve_originals_as_bak(config)returnsbool(defaultFalse)._PRESERVE_ORIGINALS_AS_BAKinphoto_path_utils.pysetonce per sync run via the new
set_preserve_originals_as_bak()setter(same pattern as
set_default_filename_format).generate_photo_filename_with_metadataappends.original.bakto thefilename when the toggle is on, file_size is
original, AND the photohas an
original_altversion on iCloud.photo.versionsare treated as "no alt"so a partial CloudKit record cannot break the filename pipeline.
Tests
10 new tests in
tests/test_preserve_originals_as_bak.py:originalgets the suffixoriginal_altdoes NOT get the suffixoriginaldoes NOT get the suffixoriginalsizes (medium, thumb, etc.) do not get the suffixphoto.versionsexception is non-fatal (falls through to normal name)Notes
.original.bakat the very end of the filename)is greppable (
find . -name "*.original.bak") and self-documenting oncasual filesystem browsing.
filename_format: metadataandfilename_format: simplemodes — the.original.bakqualifier isappended to whatever the base filename would have been.