1010The manifest lives at {download_dir}/.icloudpd.db and travels with the library.
1111"""
1212
13+ import contextlib
1314import logging
1415import os
1516import sqlite3
1920
2021logger = logging .getLogger (__name__ )
2122
22- SCHEMA_VERSION = 2
23+ SCHEMA_VERSION = 3
2324
2425_FRESH_SCHEMA = """\
2526 CREATE TABLE IF NOT EXISTS manifest (
2627 asset_id TEXT NOT NULL,
2728 zone_id TEXT NOT NULL DEFAULT '',
29+ asset_resource TEXT NOT NULL DEFAULT 'resOriginal',
2830 local_path TEXT NOT NULL,
2931 version_size INTEGER NOT NULL,
3032 version_checksum TEXT,
5860 burst_id TEXT,
5961 original_orientation INTEGER,
6062 raw_fields TEXT,
61- PRIMARY KEY (asset_id, zone_id, local_path )
63+ PRIMARY KEY (asset_id, zone_id, asset_resource )
6264);
6365CREATE INDEX IF NOT EXISTS idx_manifest_path ON manifest(local_path);
6466"""
7678 (2 , "ALTER TABLE manifest ADD COLUMN burst_id TEXT" ),
7779 (2 , "ALTER TABLE manifest ADD COLUMN original_orientation INTEGER" ),
7880 (2 , "ALTER TABLE manifest ADD COLUMN raw_fields TEXT" ),
81+ (3 , "ALTER TABLE manifest ADD COLUMN asset_resource TEXT NOT NULL DEFAULT 'resOriginal'" ),
7982]
8083
8184
@@ -85,6 +88,7 @@ class ManifestRow:
8588
8689 asset_id : str
8790 zone_id : str
91+ asset_resource : str
8892 local_path : str
8993 version_size : int
9094 version_checksum : str | None
@@ -121,7 +125,7 @@ class ManifestRow:
121125
122126
123127_ALL_COLUMNS = (
124- "asset_id, zone_id, local_path, version_size, version_checksum, "
128+ "asset_id, zone_id, asset_resource, local_path, version_size, version_checksum, "
125129 "change_tag, downloaded_at, last_updated_at, item_type, filename, "
126130 "asset_date, added_date, is_favorite, is_hidden, is_deleted, "
127131 "original_width, original_height, duration, orientation, "
@@ -211,12 +215,14 @@ def _migrate_from_v0(self) -> None:
211215 ("burst_id" , "TEXT" ),
212216 ("original_orientation" , "INTEGER" ),
213217 ("raw_fields" , "TEXT" ),
218+ ("asset_resource" , "TEXT NOT NULL DEFAULT 'resOriginal'" ),
214219 ]
215220 for col_name , col_def in new_columns :
216221 if col_name not in existing :
217222 self ._conn .execute (f"ALTER TABLE manifest ADD COLUMN { col_name } { col_def } " ) # type: ignore[union-attr]
218223 logger .info ("Migrated manifest DB from v0 to v%d (%d columns added)" ,
219224 SCHEMA_VERSION , sum (1 for c , _ in new_columns if c not in existing ))
225+ self ._rebuild_pk ()
220226 self ._conn .execute ( # type: ignore[union-attr]
221227 "CREATE INDEX IF NOT EXISTS idx_manifest_path ON manifest(local_path)"
222228 )
@@ -225,10 +231,28 @@ def _run_migrations(self, from_version: int) -> None:
225231 """Run incremental migrations from from_version to SCHEMA_VERSION."""
226232 for version , sql in _MIGRATIONS :
227233 if version > from_version :
228- self ._conn .execute (sql ) # type: ignore[union-attr]
234+ with contextlib .suppress (sqlite3 .OperationalError ):
235+ self ._conn .execute (sql ) # type: ignore[union-attr]
236+ if from_version < 3 :
237+ self ._rebuild_pk ()
229238 self ._conn .execute (f"PRAGMA user_version={ SCHEMA_VERSION } " ) # type: ignore[union-attr]
230239 self ._conn .commit () # type: ignore[union-attr]
231240
241+ def _rebuild_pk (self ) -> None :
242+ """Rebuild the manifest table with the correct PK (asset_id, zone_id, asset_resource)."""
243+ conn = self ._conn
244+ assert conn is not None
245+ conn .execute ("ALTER TABLE manifest RENAME TO manifest_old" )
246+ conn .executescript (_FRESH_SCHEMA )
247+ # Copy data, keeping only one row per (asset_id, zone_id, asset_resource)
248+ cols = _ALL_COLUMNS
249+ conn .execute (
250+ f"INSERT OR IGNORE INTO manifest ({ cols } ) "
251+ f"SELECT { cols } FROM manifest_old"
252+ )
253+ conn .execute ("DROP TABLE manifest_old" )
254+ conn .commit ()
255+
232256 def close (self ) -> None :
233257 """Close the manifest DB, committing any pending writes."""
234258 if self ._conn :
@@ -265,12 +289,12 @@ def __enter__(self) -> "ManifestDB":
265289 def __exit__ (self , * _ : object ) -> None :
266290 self .close ()
267291
268- def lookup (self , asset_id : str , zone_id : str , local_path : str ) -> ManifestRow | None :
292+ def lookup (self , asset_id : str , zone_id : str , asset_resource : str ) -> ManifestRow | None :
269293 """Look up a manifest entry by identity."""
270294 row = self ._db .execute (
271295 f"SELECT { _ALL_COLUMNS } FROM manifest "
272- "WHERE asset_id = ? AND zone_id = ? AND local_path = ?" ,
273- (asset_id , zone_id , local_path ),
296+ "WHERE asset_id = ? AND zone_id = ? AND asset_resource = ?" ,
297+ (asset_id , zone_id , asset_resource ),
274298 ).fetchone ()
275299 if row is None :
276300 return None
@@ -293,6 +317,7 @@ def upsert(
293317 zone_id : str ,
294318 local_path : str ,
295319 version_size : int ,
320+ asset_resource : str = "resOriginal" ,
296321 version_checksum : str | None = None ,
297322 change_tag : str | None = None ,
298323 item_type : str | None = None ,
@@ -326,7 +351,7 @@ def upsert(
326351 """Insert or update a manifest entry. Auto-flushes every 500 writes."""
327352 now = datetime .now (tz = timezone .utc ).isoformat ()
328353 params = (
329- asset_id , zone_id , local_path , version_size , version_checksum ,
354+ asset_id , zone_id , asset_resource , local_path , version_size , version_checksum ,
330355 change_tag , now , now , item_type , filename ,
331356 asset_date , added_date , is_favorite , is_hidden , is_deleted ,
332357 original_width , original_height , duration , orientation ,
@@ -336,8 +361,9 @@ def upsert(
336361 )
337362 sql = (
338363 f"INSERT INTO manifest ({ _ALL_COLUMNS } ) "
339- "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "
340- "ON CONFLICT(asset_id, zone_id, local_path) DO UPDATE SET "
364+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "
365+ "ON CONFLICT(asset_id, zone_id, asset_resource) DO UPDATE SET "
366+ "local_path=excluded.local_path, "
341367 "version_size=excluded.version_size, "
342368 "version_checksum=excluded.version_checksum, "
343369 "change_tag=excluded.change_tag, "
@@ -392,21 +418,21 @@ def upsert(
392418 logger .warning ("Manifest write failed for %s: %s" , local_path , e )
393419 return
394420
395- def update_path (self , asset_id : str , zone_id : str , old_path : str , new_path : str ) -> None :
421+ def update_path (self , asset_id : str , zone_id : str , asset_resource : str , new_path : str ) -> None :
396422 """Update local_path for an existing manifest entry."""
397423 try :
398424 self ._db .execute (
399425 "UPDATE manifest SET local_path = ?, last_updated_at = ? "
400- "WHERE asset_id = ? AND zone_id = ? AND local_path = ?" ,
426+ "WHERE asset_id = ? AND zone_id = ? AND asset_resource = ?" ,
401427 (new_path , datetime .now (tz = timezone .utc ).isoformat (),
402- asset_id , zone_id , old_path ),
428+ asset_id , zone_id , asset_resource ),
403429 )
404430 self ._dirty = True
405431 self ._pending_count += 1
406432 except sqlite3 .Error as e :
407433 logger .warning (
408434 "Manifest path update failed for %s -> %s: %s" ,
409- old_path , new_path , e ,
435+ asset_resource , new_path , e ,
410436 )
411437
412438 def count_by_path (self , local_path : str ) -> int :
@@ -417,11 +443,11 @@ def count_by_path(self, local_path: str) -> int:
417443 ).fetchone ()
418444 return row [0 ] if row else 0
419445
420- def remove (self , asset_id : str , zone_id : str , local_path : str ) -> None :
446+ def remove (self , asset_id : str , zone_id : str , asset_resource : str ) -> None :
421447 """Remove a manifest entry."""
422448 self ._db .execute (
423- "DELETE FROM manifest WHERE asset_id = ? AND zone_id = ? AND local_path = ?" ,
424- (asset_id , zone_id , local_path ),
449+ "DELETE FROM manifest WHERE asset_id = ? AND zone_id = ? AND asset_resource = ?" ,
450+ (asset_id , zone_id , asset_resource ),
425451 )
426452 self ._dirty = True
427453
0 commit comments