Skip to content

Commit 28203ce

Browse files
teh-hippoCopilot
andcommitted
feat: schema v2 with 10 new columns, add location to --write-metadata, deprecate dates
- Manifest DB schema v2: add gps_speed, gps_timestamp, timezone_offset, asset_subtype, hdr_type, burst_flags, burst_flags_ext, burst_id, original_orientation, and raw_fields (JSON blob) columns - Migration support for v0->v2 and v1->v2 upgrades - Add 'location' category to --write-metadata for GPS lat/lon/alt with float tolerance (~1m) for idempotent delta detection - Deprecate 'dates' category (accepted with warning, no-op) - Guard orientation=0 (invalid EXIF, treated as None at extract time) - Populate all new DB columns from iCloud API in _extract_manifest_metadata - 93 tests covering all changes (22 manifest, 61 metadata, 10 CLI) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f87e043 commit 28203ce

File tree

6 files changed

+624
-74
lines changed

6 files changed

+624
-74
lines changed

src/icloudpd/base.py

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,18 +49,19 @@
4949
from icloudpd.filename_policies import build_filename_with_policies, create_filename_builder
5050
from icloudpd.log_level import LogLevel
5151
from icloudpd.manifest import ManifestDB
52+
from icloudpd.metadata_writer import (
53+
extract_metadata_update,
54+
)
55+
from icloudpd.metadata_writer import (
56+
write_metadata as write_file_metadata,
57+
)
5258
from icloudpd.mfa_provider import MFAProvider
5359
from icloudpd.password_provider import PasswordProvider
5460
from icloudpd.paths import local_download_path, remove_unicode_chars
5561
from icloudpd.server import serve_app
5662
from icloudpd.status import Status, StatusExchange
5763
from icloudpd.string_helpers import parse_timestamp_or_timedelta, truncate_middle
5864
from icloudpd.xmp_sidecar import build_metadata, generate_xmp_file
59-
from icloudpd.metadata_writer import (
60-
MetadataUpdate,
61-
extract_metadata_update,
62-
write_metadata as write_file_metadata,
63-
)
6465
from pyicloud_ipd.asset_version import (
6566
AssetVersion,
6667
add_suffix_to_filename,
@@ -253,7 +254,7 @@ def run_with_configs(global_config: GlobalConfig, user_configs: Sequence[UserCon
253254

254255
# Check exiftool availability if any user has --write-metadata
255256
if any(uc.write_metadata for uc in user_configs):
256-
from icloudpd.metadata_writer import check_exiftool, ExiftoolNotFoundError
257+
from icloudpd.metadata_writer import ExiftoolNotFoundError, check_exiftool
257258
try:
258259
ver = check_exiftool()
259260
logger.info("exiftool %s found for --write-metadata", ver)
@@ -667,12 +668,60 @@ def _extract_manifest_metadata(photo: PhotoAsset, version: AssetVersion) -> Dict
667668
gps_latitude = xmp.GPSLatitude
668669
gps_longitude = xmp.GPSLongitude
669670
gps_altitude = xmp.GPSAltitude
671+
gps_speed = xmp.GPSSpeed
672+
gps_timestamp = xmp.GPSTimeStamp.isoformat() if xmp.GPSTimeStamp else None
670673
orientation = xmp.Orientation
671674
except Exception:
672675
title = description = keywords = None
673676
gps_latitude = gps_longitude = gps_altitude = None
677+
gps_speed = None
678+
gps_timestamp = None
674679
orientation = None
675680

681+
# Timezone offset
682+
timezone_offset = fields.get("timeZoneOffset", {}).get("value")
683+
if timezone_offset is not None:
684+
with contextlib.suppress(ValueError, TypeError):
685+
timezone_offset = int(timezone_offset)
686+
687+
# Asset subtype / HDR / burst metadata
688+
asset_subtype = fields.get("assetSubtypeV2", {}).get("value")
689+
if asset_subtype is not None:
690+
with contextlib.suppress(ValueError, TypeError):
691+
asset_subtype = int(asset_subtype)
692+
693+
hdr_type = fields.get("assetHDRType", {}).get("value")
694+
if hdr_type is not None:
695+
with contextlib.suppress(ValueError, TypeError):
696+
hdr_type = int(hdr_type)
697+
698+
burst_flags = fields.get("burstFlags", {}).get("value")
699+
if burst_flags is not None:
700+
with contextlib.suppress(ValueError, TypeError):
701+
burst_flags = int(burst_flags)
702+
703+
burst_flags_ext = fields.get("burstFlagsExt", {}).get("value")
704+
if burst_flags_ext is not None:
705+
with contextlib.suppress(ValueError, TypeError):
706+
burst_flags_ext = int(burst_flags_ext)
707+
708+
burst_id = fields.get("burstId", {}).get("value")
709+
if burst_id is not None:
710+
burst_id = str(burst_id)
711+
712+
# Original orientation from master record
713+
original_orientation = None
714+
try:
715+
master_fields = photo._master_record.get("fields", {})
716+
oo_val = master_fields.get("originalOrientation", {}).get("value")
717+
if oo_val is not None:
718+
original_orientation = int(oo_val)
719+
except (KeyError, TypeError, ValueError):
720+
pass
721+
722+
# Full asset record fields as JSON blob
723+
raw_fields = _json.dumps(photo._asset_record.get("fields", {}), default=str)
724+
676725
return {
677726
"version_checksum": version.checksum,
678727
"change_tag": photo._asset_record.get("recordChangeTag"),
@@ -693,6 +742,16 @@ def _extract_manifest_metadata(photo: PhotoAsset, version: AssetVersion) -> Dict
693742
"gps_latitude": gps_latitude,
694743
"gps_longitude": gps_longitude,
695744
"gps_altitude": gps_altitude,
745+
"gps_speed": gps_speed,
746+
"gps_timestamp": gps_timestamp,
747+
"timezone_offset": timezone_offset,
748+
"asset_subtype": asset_subtype,
749+
"hdr_type": hdr_type,
750+
"burst_flags": burst_flags,
751+
"burst_flags_ext": burst_flags_ext,
752+
"burst_id": burst_id,
753+
"original_orientation": original_orientation,
754+
"raw_fields": raw_fields,
696755
}
697756

698757

src/icloudpd/cli.py

Lines changed: 1 addition & 1 deletion
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"}
37+
_VALID_WRITE_METADATA = {"all", "rating", "keywords", "title", "dates", "orientation", "location"}
3838

3939

4040
def _parse_write_metadata(value: str | None) -> frozenset[str]:

src/icloudpd/manifest.py

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@
1818

1919
logger = logging.getLogger(__name__)
2020

21-
SCHEMA_VERSION = 1
21+
SCHEMA_VERSION = 2
2222

23-
_SCHEMA_V1 = """\
23+
_FRESH_SCHEMA = """\
2424
CREATE TABLE IF NOT EXISTS manifest (
2525
asset_id TEXT NOT NULL,
2626
zone_id TEXT NOT NULL DEFAULT '',
@@ -47,6 +47,16 @@
4747
gps_latitude REAL,
4848
gps_longitude REAL,
4949
gps_altitude REAL,
50+
gps_speed REAL,
51+
gps_timestamp TEXT,
52+
timezone_offset INTEGER,
53+
asset_subtype INTEGER,
54+
hdr_type INTEGER,
55+
burst_flags INTEGER,
56+
burst_flags_ext INTEGER,
57+
burst_id TEXT,
58+
original_orientation INTEGER,
59+
raw_fields TEXT,
5060
PRIMARY KEY (asset_id, zone_id, local_path)
5161
);
5262
CREATE INDEX IF NOT EXISTS idx_manifest_path ON manifest(local_path);
@@ -55,8 +65,16 @@
5565
# Columns added between schema versions, for migration from older DBs.
5666
# Each entry: (version_introduced, ALTER TABLE statement)
5767
_MIGRATIONS: list[tuple[int, str]] = [
58-
# Future migrations go here, e.g.:
59-
# (2, "ALTER TABLE manifest ADD COLUMN new_field TEXT DEFAULT NULL"),
68+
(2, "ALTER TABLE manifest ADD COLUMN gps_speed REAL"),
69+
(2, "ALTER TABLE manifest ADD COLUMN gps_timestamp TEXT"),
70+
(2, "ALTER TABLE manifest ADD COLUMN timezone_offset INTEGER"),
71+
(2, "ALTER TABLE manifest ADD COLUMN asset_subtype INTEGER"),
72+
(2, "ALTER TABLE manifest ADD COLUMN hdr_type INTEGER"),
73+
(2, "ALTER TABLE manifest ADD COLUMN burst_flags INTEGER"),
74+
(2, "ALTER TABLE manifest ADD COLUMN burst_flags_ext INTEGER"),
75+
(2, "ALTER TABLE manifest ADD COLUMN burst_id TEXT"),
76+
(2, "ALTER TABLE manifest ADD COLUMN original_orientation INTEGER"),
77+
(2, "ALTER TABLE manifest ADD COLUMN raw_fields TEXT"),
6078
]
6179

6280

@@ -89,14 +107,26 @@ class ManifestRow:
89107
gps_latitude: float | None
90108
gps_longitude: float | None
91109
gps_altitude: float | None
110+
gps_speed: float | None
111+
gps_timestamp: str | None
112+
timezone_offset: int | None
113+
asset_subtype: int | None
114+
hdr_type: int | None
115+
burst_flags: int | None
116+
burst_flags_ext: int | None
117+
burst_id: str | None
118+
original_orientation: int | None
119+
raw_fields: str | None
92120

93121

94122
_ALL_COLUMNS = (
95123
"asset_id, zone_id, local_path, version_size, version_checksum, "
96124
"change_tag, downloaded_at, last_updated_at, item_type, filename, "
97125
"asset_date, added_date, is_favorite, is_hidden, is_deleted, "
98126
"original_width, original_height, duration, orientation, "
99-
"title, description, keywords, gps_latitude, gps_longitude, gps_altitude"
127+
"title, description, keywords, gps_latitude, gps_longitude, gps_altitude, "
128+
"gps_speed, gps_timestamp, timezone_offset, asset_subtype, hdr_type, "
129+
"burst_flags, burst_flags_ext, burst_id, original_orientation, raw_fields"
100130
)
101131

102132

@@ -133,7 +163,7 @@ def open(self) -> None:
133163
).fetchone()
134164
if tables is None:
135165
# Brand new DB
136-
self._conn.executescript(_SCHEMA_V1)
166+
self._conn.executescript(_FRESH_SCHEMA)
137167
else:
138168
# Pre-versioned DB (has table but no user_version) — migrate
139169
self._migrate_from_v0()
@@ -167,6 +197,16 @@ def _migrate_from_v0(self) -> None:
167197
("gps_latitude", "REAL"),
168198
("gps_longitude", "REAL"),
169199
("gps_altitude", "REAL"),
200+
("gps_speed", "REAL"),
201+
("gps_timestamp", "TEXT"),
202+
("timezone_offset", "INTEGER"),
203+
("asset_subtype", "INTEGER"),
204+
("hdr_type", "INTEGER"),
205+
("burst_flags", "INTEGER"),
206+
("burst_flags_ext", "INTEGER"),
207+
("burst_id", "TEXT"),
208+
("original_orientation", "INTEGER"),
209+
("raw_fields", "TEXT"),
170210
]
171211
for col_name, col_def in new_columns:
172212
if col_name not in existing:
@@ -256,13 +296,23 @@ def upsert(
256296
gps_latitude: float | None = None,
257297
gps_longitude: float | None = None,
258298
gps_altitude: float | None = None,
299+
gps_speed: float | None = None,
300+
gps_timestamp: str | None = None,
301+
timezone_offset: int | None = None,
302+
asset_subtype: int | None = None,
303+
hdr_type: int | None = None,
304+
burst_flags: int | None = None,
305+
burst_flags_ext: int | None = None,
306+
burst_id: str | None = None,
307+
original_orientation: int | None = None,
308+
raw_fields: str | None = None,
259309
) -> None:
260310
"""Insert or update a manifest entry. Auto-flushes every 500 writes."""
261311
try:
262312
now = datetime.now(tz=timezone.utc).isoformat()
263313
self._db.execute(
264314
f"INSERT INTO manifest ({_ALL_COLUMNS}) "
265-
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "
315+
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "
266316
"ON CONFLICT(asset_id, zone_id, local_path) DO UPDATE SET "
267317
"version_size=excluded.version_size, "
268318
"version_checksum=excluded.version_checksum, "
@@ -284,13 +334,25 @@ def upsert(
284334
"keywords=excluded.keywords, "
285335
"gps_latitude=excluded.gps_latitude, "
286336
"gps_longitude=excluded.gps_longitude, "
287-
"gps_altitude=excluded.gps_altitude",
337+
"gps_altitude=excluded.gps_altitude, "
338+
"gps_speed=excluded.gps_speed, "
339+
"gps_timestamp=excluded.gps_timestamp, "
340+
"timezone_offset=excluded.timezone_offset, "
341+
"asset_subtype=excluded.asset_subtype, "
342+
"hdr_type=excluded.hdr_type, "
343+
"burst_flags=excluded.burst_flags, "
344+
"burst_flags_ext=excluded.burst_flags_ext, "
345+
"burst_id=excluded.burst_id, "
346+
"original_orientation=excluded.original_orientation, "
347+
"raw_fields=excluded.raw_fields",
288348
(
289349
asset_id, zone_id, local_path, version_size, version_checksum,
290350
change_tag, now, now, item_type, filename,
291351
asset_date, added_date, is_favorite, is_hidden, is_deleted,
292352
original_width, original_height, duration, orientation,
293353
title, description, keywords, gps_latitude, gps_longitude, gps_altitude,
354+
gps_speed, gps_timestamp, timezone_offset, asset_subtype, hdr_type,
355+
burst_flags, burst_flags_ext, burst_id, original_orientation, raw_fields,
294356
),
295357
)
296358
self._dirty = True

0 commit comments

Comments
 (0)