4040
4141
4242class FmdClient :
43- def __init__ (self , base_url : str , session_duration : int = 3600 , * , cache_ttl : int = 30 ):
43+ def __init__ (self , base_url : str , session_duration : int = 3600 , * , cache_ttl : int = 30 , timeout : float = 30.0 ):
4444 self .base_url = base_url .rstrip ('/' )
4545 self .session_duration = session_duration
4646 self .cache_ttl = cache_ttl
47+ self .timeout = timeout # default timeout for all HTTP requests (seconds)
4748
4849 self ._fmd_id : Optional [str ] = None
4950 self ._password : Optional [str ] = None
@@ -53,8 +54,17 @@ def __init__(self, base_url: str, session_duration: int = 3600, *, cache_ttl: in
5354 self ._session : Optional [aiohttp .ClientSession ] = None
5455
5556 @classmethod
56- async def create (cls , base_url : str , fmd_id : str , password : str , session_duration : int = 3600 ):
57- inst = cls (base_url , session_duration )
57+ async def create (
58+ cls ,
59+ base_url : str ,
60+ fmd_id : str ,
61+ password : str ,
62+ session_duration : int = 3600 ,
63+ * ,
64+ cache_ttl : int = 30 ,
65+ timeout : float = 30.0
66+ ):
67+ inst = cls (base_url , session_duration , cache_ttl = cache_ttl , timeout = timeout )
5868 inst ._fmd_id = fmd_id
5969 inst ._password = password
6070 await inst .authenticate (fmd_id , password , session_duration )
@@ -170,15 +180,16 @@ def decrypt_data_blob(self, data_b64: str) -> bytes:
170180 # HTTP helper
171181 # -------------------------
172182 async def _make_api_request (self , method : str , endpoint : str , payload : Any ,
173- stream : bool = False , expect_json : bool = True , retry_auth : bool = True ):
183+ stream : bool = False , expect_json : bool = True , retry_auth : bool = True , timeout : Optional [ float ] = None ):
174184 """
175185 Makes an API request and returns Data or text depending on expect_json/stream.
176186 Mirrors get_all_locations/_make_api_request logic from original file (including 401 re-auth).
177187 """
178188 url = self .base_url + endpoint
179189 await self ._ensure_session ()
190+ req_timeout = aiohttp .ClientTimeout (total = timeout if timeout is not None else self .timeout )
180191 try :
181- async with self ._session .request (method , url , json = payload ) as resp :
192+ async with self ._session .request (method , url , json = payload , timeout = req_timeout ) as resp :
182193 # Handle 401 -> re-authenticate once
183194 if resp .status == 401 and retry_auth and self ._fmd_id and self ._password :
184195 log .info ("Received 401 Unauthorized, re-authenticating..." )
@@ -187,7 +198,7 @@ async def _make_api_request(self, method: str, endpoint: str, payload: Any,
187198 payload ["IDT" ] = self .access_token
188199 return await self ._make_api_request (
189200 method , endpoint , payload , stream , expect_json ,
190- retry_auth = False )
201+ retry_auth = False , timeout = timeout )
191202
192203 resp .raise_for_status ()
193204 log .debug (
@@ -294,13 +305,15 @@ async def get_locations(
294305
295306 return locations
296307
297- async def get_pictures (self , num_to_get : int = - 1 ) -> List [Any ]:
308+ async def get_pictures (self , num_to_get : int = - 1 , timeout : Optional [ float ] = None ) -> List [Any ]:
298309 """Fetches all or the N most recent picture metadata blobs (raw server response)."""
310+ req_timeout = aiohttp .ClientTimeout (total = timeout if timeout is not None else self .timeout )
299311 try :
300312 await self ._ensure_session ()
301313 async with self ._session .put (
302314 f"{ self .base_url } /api/v1/pictures" ,
303- json = {"IDT" : self .access_token , "Data" : "" }) as resp :
315+ json = {"IDT" : self .access_token , "Data" : "" },
316+ timeout = req_timeout ) as resp :
304317 resp .raise_for_status ()
305318 json_data = await resp .json ()
306319 # Extract the Data field if it exists, otherwise use the response as-is
@@ -322,30 +335,118 @@ async def get_pictures(self, num_to_get: int = -1) -> List[Any]:
322335 log .info (f"Found { len (all_pictures )} pictures. Selecting the { num_to_download } most recent." )
323336 return all_pictures [- num_to_download :][::- 1 ]
324337
325- async def export_data_zip (self , out_path : str , session_duration : int = 3600 , fallback : bool = True ):
326- url = f"{ self .base_url } /api/v1/exportData"
338+ async def export_data_zip (self , out_path : str , include_pictures : bool = True ) -> str :
339+ """
340+ Export all account data to a ZIP file (client-side packaging).
341+
342+ This mimics the FMD web UI's export functionality by fetching all locations
343+ and pictures via the existing API endpoints, decrypting them, and packaging
344+ them into a user-friendly ZIP file.
345+
346+ NOTE: There is no server-side /api/v1/exportData endpoint. This method
347+ performs client-side data collection, decryption, and packaging, similar
348+ to how the web UI implements its export feature.
349+
350+ ZIP Contents:
351+ - info.json: Export metadata (date, device ID, counts)
352+ - locations.json: Decrypted location data (human-readable JSON)
353+ - pictures/picture_NNNN.jpg: Extracted picture files
354+ - pictures/manifest.json: Picture metadata (filename, size, index)
355+
356+ Args:
357+ out_path: Path where the ZIP file will be saved
358+ include_pictures: Whether to include pictures in the export (default: True)
359+
360+ Returns:
361+ Path to the created ZIP file
362+
363+ Raises:
364+ FmdApiException: If data fetching or ZIP creation fails
365+ """
366+ import zipfile
367+ from datetime import datetime
368+
327369 try :
328- await self ._ensure_session ()
329- # POST with session request; stream the response to file
330- async with self ._session .post (url , json = {"session" : session_duration }) as resp :
331- if resp .status == 404 :
332- raise FmdApiException ("exportData endpoint not found (404)" )
333- resp .raise_for_status ()
334- # stream to file
335- with open (out_path , "wb" ) as f :
336- async for chunk in resp .content .iter_chunked (8192 ):
337- if not chunk :
338- break
339- f .write (chunk )
340- return out_path
341- except aiohttp .ClientResponseError as e :
342- # include server response text for diagnostics
343- try :
344- body = await e .response .text ()
345- except Exception :
346- body = "<no body>"
347- raise FmdApiException (f"Failed to export data: { e .status } , body={ body } " ) from e
370+ log .info ("Starting data export (client-side packaging)..." )
371+
372+ # Fetch all locations
373+ log .info ("Fetching all locations..." )
374+ location_blobs = await self .get_locations (num_to_get = - 1 , skip_empty = False )
375+
376+ # Fetch all pictures if requested
377+ picture_blobs = []
378+ if include_pictures :
379+ log .info ("Fetching all pictures..." )
380+ picture_blobs = await self .get_pictures (num_to_get = - 1 )
381+
382+ # Create ZIP file with exported data
383+ log .info (f"Creating export ZIP at { out_path } ..." )
384+ with zipfile .ZipFile (out_path , 'w' , zipfile .ZIP_DEFLATED ) as zipf :
385+ # Decrypt and add readable locations
386+ decrypted_locations = []
387+ if location_blobs :
388+ log .info (f"Decrypting { len (location_blobs )} locations..." )
389+ for i , blob in enumerate (location_blobs ):
390+ try :
391+ decrypted = self .decrypt_data_blob (blob )
392+ loc_data = json .loads (decrypted )
393+ decrypted_locations .append (loc_data )
394+ except Exception as e :
395+ log .warning (f"Failed to decrypt location { i } : { e } " )
396+ decrypted_locations .append ({"error" : str (e ), "index" : i })
397+
398+ # Decrypt and extract pictures as image files
399+ picture_file_list = []
400+ if picture_blobs :
401+ log .info (f"Decrypting and extracting { len (picture_blobs )} pictures..." )
402+ for i , blob in enumerate (picture_blobs ):
403+ try :
404+ decrypted = self .decrypt_data_blob (blob )
405+ # Pictures are double-encoded: decrypt -> base64 string -> image bytes
406+ inner_b64 = decrypted .decode ('utf-8' ).strip ()
407+ from .helpers import b64_decode_padded
408+ image_bytes = b64_decode_padded (inner_b64 )
409+
410+ # Determine image format from magic bytes
411+ if image_bytes .startswith (b'\xff \xd8 \xff ' ):
412+ ext = 'jpg'
413+ elif image_bytes .startswith (b'\x89 PNG' ):
414+ ext = 'png'
415+ else :
416+ ext = 'jpg' # default to jpg
417+
418+ filename = f"pictures/picture_{ i :04d} .{ ext } "
419+ zipf .writestr (filename , image_bytes )
420+ picture_file_list .append ({"index" : i , "filename" : filename , "size" : len (image_bytes )})
421+
422+ except Exception as e :
423+ log .warning (f"Failed to decrypt/extract picture { i } : { e } " )
424+ picture_file_list .append ({"index" : i , "error" : str (e )})
425+
426+ # Add metadata file (after processing so we have accurate counts)
427+ export_info = {
428+ "export_date" : datetime .now ().isoformat (),
429+ "fmd_id" : self ._fmd_id ,
430+ "location_count" : len (location_blobs ),
431+ "picture_count" : len (picture_blobs ),
432+ "pictures_extracted" : len ([p for p in picture_file_list if "error" not in p ]),
433+ "version" : "2.0"
434+ }
435+ zipf .writestr ("info.json" , json .dumps (export_info , indent = 2 ))
436+
437+ # Add locations as readable JSON
438+ if decrypted_locations :
439+ zipf .writestr ("locations.json" , json .dumps (decrypted_locations , indent = 2 ))
440+
441+ # Add picture manifest if we extracted any
442+ if picture_file_list :
443+ zipf .writestr ("pictures/manifest.json" , json .dumps (picture_file_list , indent = 2 ))
444+
445+ log .info (f"Export completed successfully: { out_path } " )
446+ return out_path
447+
348448 except Exception as e :
449+ log .error (f"Failed to export data: { e } " )
349450 raise FmdApiException (f"Failed to export data: { e } " ) from e
350451
351452 # -------------------------
0 commit comments