Skip to content

Commit 542f268

Browse files
teh-hippoCopilot
andcommitted
feat: split --write-metadata into xmp/exif variants
Add --write-metadata-xmp and --write-metadata-exif for independent control of XMP sidecar generation and EXIF tag writing. --write-metadata remains as shorthand for both. All three flags accept category lists: all, rating, keywords, title, datetime, dates, orientation, location. Omitting value defaults to 'all'. New categories: - datetime: DateTimeOriginal + CreateDate - dates: datetime + ModifyDate (superset) Deprecations (still functional, with warnings): - --xmp-sidecar -> --write-metadata-xmp - --set-exif-datetime -> --write-metadata-exif datetime Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b599dd7 commit 542f268

File tree

6 files changed

+308
-46
lines changed

6 files changed

+308
-46
lines changed

src/icloudpd/base.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -252,8 +252,8 @@ def run_with_configs(global_config: GlobalConfig, user_configs: Sequence[UserCon
252252
# Create shared logger
253253
logger = create_logger(global_config)
254254

255-
# Check exiftool availability if any user has --write-metadata
256-
if any(uc.write_metadata for uc in user_configs):
255+
# Check exiftool availability if any user has --write-metadata-exif
256+
if any(uc.write_metadata_exif for uc in user_configs):
257257
from icloudpd.metadata_writer import ExiftoolNotFoundError, check_exiftool
258258
try:
259259
ver = check_exiftool()
@@ -434,12 +434,12 @@ def password_provider(_username: str) -> str | None:
434434
user_config.force_size,
435435
global_config.only_print_filenames,
436436
user_config.set_exif_datetime,
437-
user_config.write_metadata,
437+
user_config.write_metadata_exif,
438438
user_config.skip_live_photos,
439439
user_config.live_photo_size,
440440
user_config.dry_run,
441441
user_config.file_match_policy,
442-
user_config.xmp_sidecar,
442+
user_config.write_metadata_xmp,
443443
lp_filename_generator,
444444
filename_builder,
445445
user_config.align_raw,
@@ -763,12 +763,12 @@ def download_builder(
763763
force_size: bool,
764764
only_print_filenames: bool,
765765
set_exif_datetime: bool,
766-
write_metadata_config: frozenset[str],
766+
write_metadata_exif: frozenset[str],
767767
skip_live_photos: bool,
768768
live_photo_size: LivePhotoVersionSize,
769769
dry_run: bool,
770770
file_match_policy: FileMatchPolicy,
771-
xmp_sidecar: bool,
771+
write_metadata_xmp: frozenset[str],
772772
lp_filename_generator: Callable[[str], str],
773773
filename_builder: Callable[[PhotoAsset], str],
774774
raw_policy: RawTreatmentPolicy,
@@ -982,13 +982,13 @@ def download_builder(
982982
)
983983
logger.info("Downloaded %s", truncated_path)
984984

985-
if xmp_sidecar:
985+
if write_metadata_xmp:
986986
generate_xmp_file(logger, download_path, photo._asset_record, dry_run, dir_cache)
987987

988-
if write_metadata_config:
988+
if write_metadata_exif:
989989
xmp_meta = build_metadata(logger, photo._asset_record)
990990
update = extract_metadata_update(photo._asset_record, xmp_meta)
991-
write_file_metadata(download_path, update, set(write_metadata_config), dry_run)
991+
write_file_metadata(download_path, update, set(write_metadata_exif), dry_run)
992992

993993
# Also download the live photo if present
994994
if not skip_live_photos:

src/icloudpd/cli.py

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ def map_align_raw_to_enum(align_raw_str: str) -> RawTreatmentPolicy:
3434
return mapping[align_raw_str]
3535

3636

37-
_VALID_WRITE_METADATA = {"all", "rating", "keywords", "title", "dates", "orientation", "location"}
37+
_VALID_WRITE_METADATA = {"all", "rating", "keywords", "title", "dates", "orientation", "location", "datetime"}
3838

3939

4040
def _parse_write_metadata(value: str | None) -> frozenset[str]:
@@ -161,11 +161,40 @@ def add_options_for_user(parser: argparse.ArgumentParser) -> argparse.ArgumentPa
161161
cloned.add_argument(
162162
"--write-metadata",
163163
help=(
164-
"Write iCloud metadata into file EXIF/XMP using exiftool. "
164+
"Write iCloud metadata as both XMP sidecars and EXIF tags. "
165+
"Shorthand for --write-metadata-xmp and --write-metadata-exif. "
165166
"Comma-separated list of field categories: "
166-
"all, rating, keywords, title, dates, orientation. "
167-
"Requires exiftool to be installed. Default: disabled."
167+
"all, rating, keywords, title, datetime, dates, orientation, location. "
168+
"Requires exiftool to be installed. "
169+
"Default when specified without value: all."
168170
),
171+
nargs="?",
172+
const="all",
173+
default=None,
174+
)
175+
cloned.add_argument(
176+
"--write-metadata-xmp",
177+
help=(
178+
"Write iCloud metadata as XMP sidecar files. "
179+
"Comma-separated list of field categories: "
180+
"all, rating, keywords, title, datetime, dates, orientation, location. "
181+
"Default when specified without value: all."
182+
),
183+
nargs="?",
184+
const="all",
185+
default=None,
186+
)
187+
cloned.add_argument(
188+
"--write-metadata-exif",
189+
help=(
190+
"Write iCloud metadata into file EXIF/XMP tags using exiftool. "
191+
"Comma-separated list of field categories: "
192+
"all, rating, keywords, title, datetime, dates, orientation, location. "
193+
"Requires exiftool to be installed. "
194+
"Default when specified without value: all."
195+
),
196+
nargs="?",
197+
const="all",
169198
default=None,
170199
)
171200

@@ -456,6 +485,37 @@ def format_help() -> str:
456485

457486

458487
def map_to_config(user_ns: argparse.Namespace) -> UserConfig:
488+
# Resolve write-metadata flags
489+
write_metadata_base = _parse_write_metadata(user_ns.write_metadata)
490+
write_metadata_xmp = _parse_write_metadata(user_ns.write_metadata_xmp)
491+
write_metadata_exif = _parse_write_metadata(user_ns.write_metadata_exif)
492+
493+
# --write-metadata sets both if specific variants not given
494+
if write_metadata_base:
495+
if not write_metadata_xmp:
496+
write_metadata_xmp = write_metadata_base
497+
if not write_metadata_exif:
498+
write_metadata_exif = write_metadata_base
499+
500+
# --xmp-sidecar is deprecated alias for --write-metadata-xmp all
501+
if user_ns.xmp_sidecar:
502+
import logging
503+
504+
logging.getLogger("icloudpd").warning(
505+
"--xmp-sidecar is deprecated, use --write-metadata-xmp instead"
506+
)
507+
if not write_metadata_xmp:
508+
write_metadata_xmp = frozenset({"all"})
509+
510+
# --set-exif-datetime adds datetime to exif set
511+
if user_ns.set_exif_datetime:
512+
import logging
513+
514+
logging.getLogger("icloudpd").warning(
515+
"--set-exif-datetime is deprecated, use --write-metadata-exif datetime instead"
516+
)
517+
write_metadata_exif = write_metadata_exif | frozenset({"datetime"})
518+
459519
return UserConfig(
460520
username=user_ns.username,
461521
password=user_ns.password,
@@ -474,12 +534,12 @@ def map_to_config(user_ns: argparse.Namespace) -> UserConfig:
474534
list_libraries=user_ns.list_libraries,
475535
skip_videos=user_ns.skip_videos,
476536
skip_live_photos=user_ns.skip_live_photos,
477-
xmp_sidecar=user_ns.xmp_sidecar,
478537
force_size=user_ns.force_size,
479538
auto_delete=user_ns.auto_delete,
480539
folder_structure=user_ns.folder_structure,
481540
set_exif_datetime=user_ns.set_exif_datetime,
482-
write_metadata=_parse_write_metadata(user_ns.write_metadata),
541+
write_metadata_xmp=write_metadata_xmp,
542+
write_metadata_exif=write_metadata_exif,
483543
smtp_username=user_ns.smtp_username,
484544
smtp_password=user_ns.smtp_password,
485545
smtp_host=user_ns.smtp_host,

src/icloudpd/config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,12 @@ class _DefaultConfig:
2727
list_libraries: bool
2828
skip_videos: bool
2929
skip_live_photos: bool
30-
xmp_sidecar: bool
3130
force_size: bool
3231
auto_delete: bool
3332
folder_structure: str
3433
set_exif_datetime: bool
35-
write_metadata: frozenset[str]
34+
write_metadata_xmp: frozenset[str]
35+
write_metadata_exif: frozenset[str]
3636
smtp_username: str | None
3737
smtp_password: str | None
3838
smtp_host: str

src/icloudpd/metadata_writer.py

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ class MetadataUpdate:
3232
gps_longitude: float | None = None
3333
gps_altitude: float | None = None
3434
gps_h_accuracy: float | None = None
35+
created_date: str | None = None # format: "YYYY:MM:DD HH:MM:SS"
3536

3637

3738
class ExiftoolNotFoundError(RuntimeError):
@@ -112,6 +113,13 @@ def build_exiftool_args(
112113
if update.gps_h_accuracy is not None and update.gps_h_accuracy > 0:
113114
args.append(f"-GPSHPositioningError={update.gps_h_accuracy}")
114115

116+
if (write_all or "datetime" in config or "dates" in config) and update.created_date is not None:
117+
args.append(f"-EXIF:DateTimeOriginal={update.created_date}")
118+
args.append(f"-EXIF:CreateDate={update.created_date}")
119+
120+
if (write_all or "dates" in config) and update.created_date is not None:
121+
args.append(f"-EXIF:ModifyDate={update.created_date}")
122+
115123
return args
116124

117125

@@ -158,6 +166,10 @@ def extract_metadata_update(
158166
gps_altitude = xmp_metadata.GPSAltitude if xmp_metadata.GPSAltitude is not None else None
159167
# horzAcc is not in XMPMetadata — extract from raw locationEnc
160168
gps_h_accuracy = _extract_h_accuracy(asset_record)
169+
# Extract created_date for datetime/dates categories
170+
created_date_val = None
171+
if xmp_metadata.CreateDate:
172+
created_date_val = xmp_metadata.CreateDate.strftime("%Y:%m:%d %H:%M:%S")
161173
return MetadataUpdate(
162174
rating=rating,
163175
title=title,
@@ -168,6 +180,7 @@ def extract_metadata_update(
168180
gps_longitude=gps_longitude,
169181
gps_altitude=gps_altitude,
170182
gps_h_accuracy=gps_h_accuracy,
183+
created_date=created_date_val,
171184
)
172185

173186
# Fallback: extract from raw asset_record (same logic as xmp_sidecar.build_metadata)
@@ -182,12 +195,22 @@ def extract_metadata_update(
182195

183196

184197
def _read_existing_metadata(file_path: str) -> dict[str, str]:
185-
"""Read current metadata values using exiftool for comparison."""
198+
"""Read current metadata values using exiftool for comparison.
199+
200+
Uses -G to get group-prefixed keys, then normalises to unprefixed names.
201+
For GPS, prefers XMP values (where we write for videos) over Composite
202+
values (which read from QuickTime Keys and may differ in precision).
203+
"""
186204
cmd = [
187-
"exiftool", "-json", "-s", "-n",
205+
"exiftool", "-json", "-s", "-n", "-G",
188206
"-Rating", "-XPTitle", "-XPKeywords", "-ImageDescription",
189-
"-Orientation#", "-GPSLatitude", "-GPSLongitude", "-GPSAltitude",
207+
"-Orientation#",
208+
"-GPSLatitude", "-GPSLongitude", "-GPSAltitude",
190209
"-GPSHPositioningError",
210+
"-XMP-exif:GPSLatitude", "-XMP-exif:GPSLongitude",
211+
"-XMP-exif:GPSAltitude", "-XMP-exif:GPSHPositioningError",
212+
"-EXIF:DateTimeOriginal",
213+
"-QuickTime:CreateDate",
191214
file_path,
192215
]
193216
try:
@@ -196,7 +219,26 @@ def _read_existing_metadata(file_path: str) -> dict[str, str]:
196219
logger.debug("exiftool read failed for %s: %s", file_path, result.stderr.strip())
197220
return {}
198221
data = json.loads(result.stdout)
199-
return data[0] if data else {}
222+
if not data:
223+
return {}
224+
raw = data[0]
225+
# Normalise: strip group prefix. Priority for GPS comparison:
226+
# XMP > Composite > EXIF. XMP has our written values for videos.
227+
# Composite has correctly-signed values. EXIF GPS stores unsigned
228+
# values with separate Ref tags, so is unreliable for comparison.
229+
priority = {"EXIF": 0, "Composite": 1, "XMP": 2}
230+
sorted_items = sorted(
231+
raw.items(),
232+
key=lambda kv: priority.get(kv[0].split(":")[0], 1)
233+
)
234+
meta: dict[str, Any] = {}
235+
for key, val in sorted_items:
236+
if key == "SourceFile":
237+
continue
238+
parts = key.split(":", 1)
239+
tag = parts[-1]
240+
meta[tag] = val
241+
return meta
200242
except (subprocess.TimeoutExpired, FileNotFoundError, ValueError) as e:
201243
logger.debug("Could not read existing metadata from %s: %s", file_path, e)
202244
return {}
@@ -241,6 +283,11 @@ def _needs_update(
241283
if update.gps_h_accuracy is not None and update.gps_h_accuracy > 0 and not _gps_close(existing.get("GPSHPositioningError"), update.gps_h_accuracy, tol=0.1):
242284
return True
243285

286+
if (write_all or "datetime" in config or "dates" in config) and update.created_date is not None:
287+
existing_dt = existing.get("DateTimeOriginal") or existing.get("CreateDate")
288+
if existing_dt is None:
289+
return True
290+
244291
return False
245292

246293

@@ -265,13 +312,6 @@ def write_metadata(
265312
True if metadata was written (or would be written in dry-run), False if
266313
no changes were needed or an error occurred.
267314
"""
268-
# Log deprecation warning for 'dates' category
269-
if "dates" in config:
270-
logger.warning(
271-
"--write-metadata 'dates' is deprecated and has no effect. "
272-
"Timezone data is managed by the camera EXIF."
273-
)
274-
275315
args = build_exiftool_args(update, config)
276316
if not args:
277317
return False
@@ -281,6 +321,16 @@ def write_metadata(
281321
if existing and not _needs_update(update, config, existing):
282322
return False
283323

324+
# Don't overwrite existing dates — the camera's value is ground truth;
325+
# iCloud's assetDate may differ due to timezone/rounding.
326+
# Check both EXIF (images) and QuickTime (videos) date tags.
327+
if existing and (existing.get("DateTimeOriginal") is not None or existing.get("CreateDate") is not None):
328+
args = [a for a in args if not a.startswith("-EXIF:DateTimeOriginal=")
329+
and not a.startswith("-EXIF:CreateDate=")
330+
and not a.startswith("-EXIF:ModifyDate=")]
331+
if not args:
332+
return False
333+
284334
if dry_run:
285335
logger.info(
286336
"Would write metadata to %s: %s",
@@ -292,7 +342,7 @@ def write_metadata(
292342
cmd = ["exiftool", "-overwrite_original"] + args + [file_path]
293343
try:
294344
result = subprocess.run(
295-
cmd, capture_output=True, text=True, timeout=120
345+
cmd, capture_output=True, text=True, timeout=600
296346
)
297347
if result.returncode == 0 and "1 image files updated" in result.stdout:
298348
logger.info("Wrote metadata to %s", file_path)

tests/test_cli.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -211,12 +211,12 @@ def test_cli_parser(self) -> None:
211211
list_libraries=False,
212212
skip_videos=False,
213213
skip_live_photos=False,
214-
xmp_sidecar=False,
215214
force_size=False,
216215
auto_delete=False,
217216
folder_structure="{:%Y/%m/%d}",
218217
set_exif_datetime=False,
219-
write_metadata=frozenset(),
218+
write_metadata_xmp=frozenset(),
219+
write_metadata_exif=frozenset(),
220220
smtp_username=None,
221221
smtp_password=None,
222222
smtp_host="smtp.gmail.com",
@@ -252,12 +252,12 @@ def test_cli_parser(self) -> None:
252252
list_libraries=False,
253253
skip_videos=False,
254254
skip_live_photos=False,
255-
xmp_sidecar=False,
256255
force_size=False,
257256
auto_delete=False,
258257
folder_structure="{:%Y/%m/%d}",
259258
set_exif_datetime=False,
260-
write_metadata=frozenset(),
259+
write_metadata_xmp=frozenset(),
260+
write_metadata_exif=frozenset(),
261261
smtp_username=None,
262262
smtp_password=None,
263263
smtp_host="smtp.gmail.com",
@@ -329,12 +329,12 @@ def test_cli_parser(self) -> None:
329329
list_libraries=False,
330330
skip_videos=False,
331331
skip_live_photos=False,
332-
xmp_sidecar=False,
333332
force_size=False,
334333
auto_delete=False,
335334
folder_structure="{:%Y/%m/%d}",
336335
set_exif_datetime=False,
337-
write_metadata=frozenset(),
336+
write_metadata_xmp=frozenset(),
337+
write_metadata_exif=frozenset(),
338338
smtp_username=None,
339339
smtp_password=None,
340340
smtp_host="smtp.gmail.com",

0 commit comments

Comments
 (0)