diff --git a/README.md b/README.md index 1518a151..0fe56f42 100644 --- a/README.md +++ b/README.md @@ -498,6 +498,22 @@ club_stats = client.get_golf_club_stats() user_stats = client.get_golf_user_stats() ``` +### Activity Photos + +Photos attached to an activity are available inside the `get_activity()` +response at `metadataDTO.activityImages`; the `get_activity_images()` +helper returns that list directly: + +```python +# [{imageId, url, photoDate}, ...] or [] when the activity has no photos +images = client.get_activity_images(activity_id) +``` + +The `url` values are presigned S3 links that expire after 24 hours, so +download the images promptly instead of storing the URLs. The `hasImages` +flag in `get_activities()` results is not a reliable indicator that an +activity has photos attached. + ### Additional Resources - **Simple Example**: [example.py](https://raw.githubusercontent.com/cyberjunky/python-garminconnect/master/example.py) - Getting started guide - **Comprehensive Demo**: [demo.py](https://raw.githubusercontent.com/cyberjunky/python-garminconnect/master/demo.py) - All 154+ API methods diff --git a/garminconnect/__init__.py b/garminconnect/__init__.py index 1f1015f9..113cc893 100644 --- a/garminconnect/__init__.py +++ b/garminconnect/__init__.py @@ -3388,6 +3388,22 @@ def get_activity_details( return self.connectapi(url, params=params) + def get_activity_images(self, activity_id: int | str) -> list[dict[str, Any]]: + """Return the photos attached to an activity. + + Photos are surfaced in ``metadataDTO.activityImages`` of the + `get_activity` response; there is no separate images endpoint. + Each entry contains ``imageId``, ``url``, and ``photoDate``. + + Note: the ``url`` values are presigned S3 links + (``X-Amz-Expires=86400``) that expire after 24 hours, so download + the images promptly rather than storing the URLs. The + ``hasImages`` flag in `get_activities` results is not a reliable + indicator that an activity has photos attached. + """ + activity = self.get_activity(str(activity_id)) or {} + return (activity.get("metadataDTO") or {}).get("activityImages") or [] + def get_activity_exercise_sets(self, activity_id: int | str) -> dict[str, Any]: """Return activity exercise sets.""" activity_id = str(_validate_positive_integer(int(activity_id), "activity_id")) diff --git a/tests/test_garmin_unit.py b/tests/test_garmin_unit.py index 23055485..2feddaec 100644 --- a/tests/test_garmin_unit.py +++ b/tests/test_garmin_unit.py @@ -1739,6 +1739,43 @@ def test_get_available_badges_sets_exclusive_badge_flag( params = mock.call_args.kwargs["params"] assert params == {"showExclusiveBadge": "true"} + def test_get_activity_images_returns_images_from_metadata( + self, garmin: garminconnect.Garmin + ): + images = [ + {"imageId": "img-1", "url": "https://s3.example/1.jpg", "photoDate": None}, + {"imageId": "img-2", "url": "https://s3.example/2.jpg", "photoDate": None}, + ] + payload = {"activityId": 12345, "metadataDTO": {"activityImages": images}} + with patch.object(garmin, "get_activity", return_value=payload) as mock: + result = garmin.get_activity_images(12345) + + mock.assert_called_once_with("12345") + assert result == images + + def test_get_activity_images_returns_empty_without_metadata( + self, garmin: garminconnect.Garmin + ): + with patch.object(garmin, "get_activity", return_value={"activityId": 1}): + assert garmin.get_activity_images(1) == [] + + with patch.object(garmin, "get_activity", return_value={"metadataDTO": None}): + assert garmin.get_activity_images(1) == [] + + with patch.object( + garmin, + "get_activity", + return_value={"metadataDTO": {"activityImages": None}}, + ): + assert garmin.get_activity_images(1) == [] + + def test_get_activity_images_returns_empty_on_none_response( + self, garmin: garminconnect.Garmin + ): + # A successful response with a JSON null body parses to None. + with patch.object(garmin, "get_activity", return_value=None): + assert garmin.get_activity_images(1) == [] + def test_get_morning_training_readiness_picks_after_wakeup_entry( self, garmin: garminconnect.Garmin ):