listingsapi 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,157 @@
1
+ """Connected accounts resource — client.connected_accounts.*"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from listingsapi._types import APIObject
8
+ from listingsapi._utils import encode_location_id
9
+ from listingsapi.resources._base import APIResource
10
+
11
+
12
+ class ConnectedAccounts(APIResource):
13
+ """Manage Google and Facebook connected accounts.
14
+
15
+ Example:
16
+ accounts = client.connected_accounts.list()
17
+ url = client.connected_accounts.connect_google("https://ok.com", "https://err.com")
18
+ """
19
+
20
+ def list(
21
+ self,
22
+ *,
23
+ publisher: str | None = None,
24
+ status: str | None = None,
25
+ page: int | None = None,
26
+ per_page: int | None = None,
27
+ ) -> APIObject:
28
+ """Get connected third-party accounts with optional filters."""
29
+ params: dict[str, Any] = {}
30
+ if publisher is not None:
31
+ params["publisher"] = publisher
32
+ if status is not None:
33
+ params["status"] = status
34
+ if page is not None:
35
+ params["page"] = page
36
+ if per_page is not None:
37
+ params["perPage"] = per_page
38
+ data = self._get("connected-accounts", params)
39
+ return APIObject(data.get("data", {}).get("connectedAccountsInfo") or {})
40
+
41
+ def details(self, connected_account_id: str) -> APIObject:
42
+ """Get details for a connected account."""
43
+ data = self._get(f"connected-accounts/{connected_account_id}/details")
44
+ return APIObject(data.get("data", {}).get("connectedAccountDetails") or {})
45
+
46
+ def folders(self, connected_account_id: str, *, folder_name: str | None = None) -> list[APIObject]:
47
+ """Get folders under a connected Google account."""
48
+ params: dict[str, str] = {}
49
+ if folder_name is not None:
50
+ params["folderName"] = folder_name
51
+ data = self._get(f"connected-accounts/{connected_account_id}/folders", params)
52
+ items = data.get("data", {}).get("getFoldersUnderGoogleAccount") or []
53
+ return [APIObject(item) for item in items]
54
+
55
+ def suggestions(
56
+ self, connected_account_id: str, *, page: int | None = None, per_page: int | None = None
57
+ ) -> APIObject:
58
+ """Get suggested matches between a connected account's listings and listingsAPI locations."""
59
+ params: dict[str, Any] = {}
60
+ if page is not None:
61
+ params["page"] = page
62
+ if per_page is not None:
63
+ params["perPage"] = per_page
64
+ data = self._get(f"connected-accounts/{connected_account_id}/connection-suggestions", params)
65
+ return APIObject(data.get("data", {}).get("connectionSuggestionsForAccount") or {})
66
+
67
+ def listings(
68
+ self,
69
+ connected_account_id: str,
70
+ *,
71
+ location_info: str | None = None,
72
+ page: int | None = None,
73
+ per_page: int | None = None,
74
+ ) -> APIObject:
75
+ """Get listings the connected account has access to."""
76
+ payload: dict[str, Any] = {"connectedAccountId": connected_account_id}
77
+ if location_info is not None:
78
+ payload["locationInfo"] = location_info
79
+ if page is not None:
80
+ payload["page"] = page
81
+ if per_page is not None:
82
+ payload["perPage"] = per_page
83
+ data = self._post("connected-accounts/connected-account-listings", payload)
84
+ return APIObject(data.get("data", {}).get("connectedAccountListings") or {})
85
+
86
+ def connect_google(self, success_url: str, error_url: str) -> APIObject:
87
+ """Get a URL to connect a Google account. Valid 24 hours."""
88
+ data = self._post(
89
+ "connected-accounts/connect-google",
90
+ {"input": {"successUrl": success_url, "errorUrl": error_url}},
91
+ )
92
+ return APIObject(data.get("data", {}).get("bulkConnectLinkForGoogle") or {})
93
+
94
+ def connect_facebook(self, success_url: str, error_url: str) -> APIObject:
95
+ """Get a URL to connect a Facebook account. Valid 24 hours."""
96
+ data = self._post(
97
+ "connected-accounts/connect-facebook",
98
+ {"input": {"successUrl": success_url, "errorUrl": error_url}},
99
+ )
100
+ return APIObject(data.get("data", {}).get("bulkConnectLinkForFacebook") or {})
101
+
102
+ def disconnect_google(self, connected_account_id: str) -> APIObject:
103
+ """Disconnect a Google connected account."""
104
+ data = self._post(
105
+ "connected-accounts/disconnect-google",
106
+ {"input": {"connectedAccountId": connected_account_id}},
107
+ )
108
+ return APIObject(data.get("data", {}).get("gmbBulkDisconnect") or {})
109
+
110
+ def disconnect_facebook(self, connected_account_id: str) -> APIObject:
111
+ """Disconnect a Facebook connected account."""
112
+ data = self._post(
113
+ "connected-accounts/disconnect-facebook",
114
+ {"input": {"connectedAccountId": connected_account_id}},
115
+ )
116
+ return APIObject(data.get("data", {}).get("fbBulkDisconnect") or {})
117
+
118
+ def trigger_matches(self, connected_account_ids: list[str]) -> APIObject:
119
+ """Trigger matching of profiles to listingsAPI locations."""
120
+ data = self._post(
121
+ "connected-accounts/trigger-matches",
122
+ {"input": {"connectedAccountIds": connected_account_ids}},
123
+ )
124
+ return APIObject(data.get("data", {}).get("connectedAccountsTriggerMatches") or {})
125
+
126
+ def confirm_matches(self, match_record_ids: list[str]) -> APIObject:
127
+ """Confirm suggested matches between connected account listings and locations."""
128
+ data = self._post(
129
+ "connected-accounts/confirm-matches",
130
+ {"input": {"matchRecordIds": match_record_ids}},
131
+ )
132
+ return APIObject(data.get("data", {}).get("confirmConnectMatches") or {})
133
+
134
+ def oauth_url(
135
+ self, location_id: str | int, site: str, success_url: str, error_url: str
136
+ ) -> APIObject:
137
+ """Get a URL to connect a Google/Facebook profile to a single location. Valid 24 hours."""
138
+ data = self._post(
139
+ "locations/oauth_connect_url",
140
+ {
141
+ "input": {
142
+ "locationId": encode_location_id(location_id),
143
+ "site": site.upper(),
144
+ "successUrl": success_url,
145
+ "errorUrl": error_url,
146
+ }
147
+ },
148
+ )
149
+ return APIObject(data.get("data", {}).get("connectUrl") or {})
150
+
151
+ def oauth_disconnect(self, location_id: str | int, site: str) -> APIObject:
152
+ """Disconnect a Google/Facebook profile from a single location."""
153
+ data = self._post(
154
+ "locations/oauth-disconnect",
155
+ {"input": {"locationId": encode_location_id(location_id), "site": site.upper()}},
156
+ )
157
+ return APIObject(data.get("data", {}).get("disconnectConnectedAccountsLocations") or {})
@@ -0,0 +1,101 @@
1
+ """Listings resource — client.listings.*"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from listingsapi._types import APIObject
8
+ from listingsapi._utils import encode_location_id
9
+ from listingsapi.resources._base import APIResource
10
+
11
+
12
+ class Listings(APIResource):
13
+ """Manage directory listings for locations.
14
+
15
+ Example:
16
+ listings = client.listings.premium(16808)
17
+ for listing in listings:
18
+ print(listing.site, listing.syncStatus)
19
+ """
20
+
21
+ def premium(self, location_id: str | int) -> list[APIObject]:
22
+ """Get premium directory listings (Google, Yelp, etc.)."""
23
+ data = self._location_get(location_id, "listings/premium")
24
+ items = data.get("data", {}).get("listingsForLocation") or []
25
+ return [APIObject(item) for item in items]
26
+
27
+ def voice(self, location_id: str | int) -> list[APIObject]:
28
+ """Get voice assistant listings (Google, Alexa, Siri)."""
29
+ data = self._location_get(location_id, "voice-assistants")
30
+ items = data.get("data", {}).get("voiceAssistantsForLocation") or []
31
+ return [APIObject(item) for item in items]
32
+
33
+
34
+ def duplicates(self, location_id: str | int) -> list[APIObject]:
35
+ """Get duplicate listings for a location."""
36
+ data = self._location_get(location_id, "listings/duplicates")
37
+ items = data.get("data", {}).get("duplicateListingsForLocation") or []
38
+ return [APIObject(item) for item in items]
39
+
40
+ def all_duplicates(self, *, tag: str | None = None, page: int | None = None) -> APIObject:
41
+ """Get duplicate listings across all locations."""
42
+ params: dict[str, Any] = {}
43
+ if tag is not None:
44
+ params["tag"] = tag
45
+ if page is not None:
46
+ params["page"] = page
47
+ data = self._get("locations/listings/duplicates", params)
48
+ return APIObject(data.get("data", {}).get("duplicateListingsRollup") or {})
49
+
50
+ def mark_duplicate(self, location_id: str | int, listing_item_ids: list[str]) -> APIObject:
51
+ """Mark listing items as duplicates."""
52
+ data = self._post(
53
+ "locations/listings/mark-as-duplicate",
54
+ {"input": {"locationId": encode_location_id(location_id), "listingItemIds": listing_item_ids}},
55
+ )
56
+ return APIObject(data.get("data", {}).get("markAsDuplicate") or {})
57
+
58
+ def mark_not_duplicate(self, location_id: str | int, listing_item_ids: list[str]) -> APIObject:
59
+ """Clear duplicate status for listing items."""
60
+ data = self._post(
61
+ "locations/listings/mark-as-not-duplicate",
62
+ {"input": {"locationId": encode_location_id(location_id), "listingItemIds": listing_item_ids}},
63
+ )
64
+ return APIObject(data.get("data", {}).get("markAsNotDuplicate") or {})
65
+
66
+ def connect(
67
+ self, location_id: str | int, connected_account_listing_id: str, connected_account_id: str
68
+ ) -> APIObject:
69
+ """Link a location to a listing from a connected account."""
70
+ data = self._post(
71
+ "connected-accounts/connect-listing",
72
+ {
73
+ "input": {
74
+ "locationId": encode_location_id(location_id),
75
+ "connectedAccountListingId": connected_account_listing_id,
76
+ "connectedAccountId": connected_account_id,
77
+ }
78
+ },
79
+ )
80
+ return APIObject(data.get("data", {}).get("connectListing") or {})
81
+
82
+ def disconnect(self, location_id: str | int, site: str) -> APIObject:
83
+ """Disconnect a location from its Google or Facebook listing."""
84
+ data = self._post(
85
+ "connected-accounts/disconnect-listing",
86
+ {"input": {"locationId": encode_location_id(location_id), "site": site.upper()}},
87
+ )
88
+ return APIObject(data.get("data", {}).get("disconnectConnectedAccountsLocations") or {})
89
+
90
+ def create_gmb(
91
+ self, location_id: str | int, connected_account_id: str, *, folder_id: str | None = None
92
+ ) -> APIObject:
93
+ """Create a Google Business Profile listing for an existing location."""
94
+ payload: dict[str, Any] = {
95
+ "locationId": encode_location_id(location_id),
96
+ "connectedAccountId": connected_account_id,
97
+ }
98
+ if folder_id is not None:
99
+ payload["folderId"] = folder_id
100
+ data = self._post("locations/create/gmb-listing", {"input": payload})
101
+ return APIObject(data.get("data", {}).get("createGmbListingForLocation") or {})
@@ -0,0 +1,296 @@
1
+ """Locations resource — client.locations.*"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from listingsapi._types import APIObject, SyncPage
9
+ from listingsapi._utils import encode_location_id
10
+ from listingsapi.resources._base import APIResource
11
+
12
+
13
+ class Locations(APIResource):
14
+ """Manage locations.
15
+
16
+ Example:
17
+ # List locations
18
+ page = client.locations.list(first=10)
19
+ for loc in page:
20
+ print(loc.name, loc.city)
21
+
22
+ # Auto-paginate
23
+ for loc in client.locations.list(first=50).auto_paging_iter():
24
+ print(loc.name)
25
+
26
+ # Create
27
+ result = client.locations.create({"name": "Acme", "street": "123 Main St", ...})
28
+ """
29
+
30
+ def list(
31
+ self,
32
+ *,
33
+ first: int | None = None,
34
+ after: str | None = None,
35
+ before: str | None = None,
36
+ last: int | None = None,
37
+ ) -> SyncPage:
38
+ """List locations with cursor-based pagination.
39
+
40
+ Returns:
41
+ SyncPage of location objects. Use .auto_paging_iter() to iterate all.
42
+ """
43
+ params: dict[str, Any] = {}
44
+ if first is not None:
45
+ params["first"] = first
46
+ if after is not None:
47
+ params["after"] = after
48
+ if before is not None:
49
+ params["before"] = before
50
+ if last is not None:
51
+ params["last"] = last
52
+
53
+ data = self._get("locations", params)
54
+ all_locs = data.get("data", {}).get("allLocations") or {}
55
+ edges = all_locs.get("edges") or []
56
+ page_info = all_locs.get("pageInfo") or {}
57
+ items = [edge["node"] for edge in edges]
58
+ end_cursor = edges[-1]["cursor"] if edges else None
59
+
60
+ return SyncPage(
61
+ data=items,
62
+ has_more=page_info.get("hasNextPage", False),
63
+ end_cursor=end_cursor,
64
+ total=page_info.get("total"),
65
+ _fetch_next=lambda cursor: self.list(first=first, after=cursor),
66
+ )
67
+
68
+ def retrieve(self, location_id: str | int) -> APIObject:
69
+ """Get a single location by ID.
70
+
71
+ Args:
72
+ location_id: Numeric (e.g. 16808) or base64-encoded ID.
73
+ """
74
+ results = self.list_by_ids([location_id])
75
+ if not results:
76
+ from listingsapi.exceptions import NotFoundError
77
+
78
+ raise NotFoundError(f"Location {location_id} not found", status_code=404)
79
+ return results[0]
80
+
81
+ def list_by_ids(self, location_ids: list[str | int]) -> list[APIObject]:
82
+ """Get locations by a list of IDs (numeric or base64)."""
83
+ if not location_ids:
84
+ return []
85
+ encoded = [encode_location_id(lid) for lid in location_ids]
86
+ data = self._get("locations-by-ids", {"ids": json.dumps(encoded)})
87
+ items = data.get("data", {}).get("getLocationsByIds") or []
88
+ return [APIObject(item) for item in items]
89
+
90
+ def list_by_store_codes(self, store_codes: list[str]) -> list[APIObject]:
91
+ """Get locations matching the given store codes."""
92
+ if not store_codes:
93
+ return []
94
+ data = self._get("locations-by-store-codes", {"storeCodes": json.dumps(store_codes)})
95
+ items = data.get("data", {}).get("getLocationsByStoreCodes") or []
96
+ return [APIObject(item) for item in items]
97
+
98
+ def search(
99
+ self,
100
+ query: str,
101
+ *,
102
+ fields: list[str] | None = None,
103
+ first: int | None = None,
104
+ after: str | None = None,
105
+ before: str | None = None,
106
+ last: int | None = None,
107
+ ) -> SyncPage:
108
+ """Search locations by keyword (name, address, or store ID).
109
+
110
+ Args:
111
+ query: Search term.
112
+ fields: Restrict search to specific fields (e.g. ["name"], ["store_id"]).
113
+ """
114
+ params: dict[str, Any] = {"query": query}
115
+ if fields is not None:
116
+ params["fields"] = json.dumps(fields)
117
+ if first is not None:
118
+ params["first"] = first
119
+ if after is not None:
120
+ params["after"] = after
121
+ if before is not None:
122
+ params["before"] = before
123
+ if last is not None:
124
+ params["last"] = last
125
+
126
+ data = self._get("locations/search", params)
127
+ result = data.get("data", {}).get("searchLocations") or {}
128
+ edges = result.get("edges") or []
129
+ page_info = result.get("pageInfo") or {}
130
+ items = [e["node"] for e in edges]
131
+ end_cursor = edges[-1]["cursor"] if edges else None
132
+
133
+ return SyncPage(
134
+ data=items,
135
+ has_more=page_info.get("hasNextPage", False),
136
+ end_cursor=end_cursor,
137
+ total=page_info.get("total"),
138
+ _fetch_next=lambda cursor: self.search(query, fields=fields, first=first, after=cursor),
139
+ )
140
+
141
+
142
+
143
+ def create(self, input: dict[str, Any]) -> APIObject:
144
+ """Create a new location from a raw input dict. Pass camelCase field names.
145
+
146
+ Required by the API: name, description (min 200 characters), subCategoryId,
147
+ countryIso, and city (for countries with city-level addressing). Prefer
148
+ locations.add() for keyword arguments and client-side validation.
149
+
150
+ Args:
151
+ input: Location data dict with camelCase keys.
152
+ """
153
+ data = self._post("locations", {"input": input})
154
+ return APIObject(data.get("data", {}).get("createLocation") or {})
155
+
156
+ def add(
157
+ self,
158
+ *,
159
+ name: str,
160
+ description: str,
161
+ sub_category_id: int,
162
+ country_iso: str,
163
+ city: str | None = None,
164
+ street: str | None = None,
165
+ state_iso: str | None = None,
166
+ postal_code: str | None = None,
167
+ phone: str | None = None,
168
+ website: str | None = None,
169
+ store_id: str | None = None,
170
+ hide_address: bool | None = None,
171
+ business_hours: list[dict[str, Any]] | None = None,
172
+ folder_ids: list[str] | None = None,
173
+ service_area: dict[str, Any] | None = None,
174
+ place_action_links: list[dict[str, Any]] | None = None,
175
+ enabled_site_ids: list[int] | None = None,
176
+ additional_fields: dict[str, Any] | None = None,
177
+ ) -> APIObject:
178
+ """Create a location in one call with every mandatory field as a keyword argument.
179
+
180
+ Validates the API's create requirements client-side and raises ValidationError
181
+ before any network call, so a bad payload fails fast with a clear message.
182
+
183
+ Args:
184
+ name: Business name, 2-150 characters.
185
+ description: Business description, minimum 200 characters. Publishers use
186
+ it as the primary listing copy.
187
+ sub_category_id: Primary subcategory ID (see the subcategories endpoint
188
+ for valid IDs).
189
+ country_iso: Two-letter country ISO code (must be a supported country).
190
+ city: City. Required for countries with city-level addressing; may be
191
+ omitted only for service-area businesses with hide_address=True.
192
+ street: Street address.
193
+ state_iso: State/region ISO code.
194
+ postal_code: Postal or ZIP code.
195
+ phone: Primary phone number, validated for the country.
196
+ website: Business website URL.
197
+ store_id: Your unique store code; must be unique across the account.
198
+ hide_address: Hide the street address (service-area businesses).
199
+ business_hours: Weekly hours, e.g. [{"day": "MONDAY", "slots": [...]}].
200
+ folder_ids: Folder IDs to place the location in.
201
+ service_area: Service-area config: {businessType, regionCode, placeInfos}.
202
+ place_action_links: Google action links, e.g.
203
+ [{"placeActionType": "APPOINTMENT", "uri": "https://...", "isPreferred": True}].
204
+ enabled_site_ids: Site IDs to publish this location to, when you want a
205
+ subset of your plan's sites (see the plan-sites endpoint for IDs).
206
+ additional_fields: Extra camelCase fields merged into the payload as-is.
207
+
208
+ Returns:
209
+ The createLocation payload; .location holds the new location and .success
210
+ is True on success.
211
+
212
+ Example:
213
+ result = client.locations.add(
214
+ name="Acme Dental",
215
+ description="Acme Dental is a family-owned dental practice...", # 200+ chars
216
+ sub_category_id=1432,
217
+ country_iso="US",
218
+ city="New York",
219
+ street="123 Jump Street",
220
+ postal_code="10013",
221
+ phone="6443859313",
222
+ )
223
+ print(result.location.id)
224
+ """
225
+ problems: list[str] = []
226
+ if not name or not (2 <= len(name.strip()) <= 150):
227
+ problems.append("name must be 2-150 characters")
228
+ if not description or len(description.strip()) < 200:
229
+ got = len(description.strip()) if description else 0
230
+ problems.append(f"description must be at least 200 characters (got {got})")
231
+ if not sub_category_id:
232
+ problems.append("sub_category_id is required")
233
+ if not country_iso or not str(country_iso).strip():
234
+ problems.append("country_iso is required")
235
+ if not (city and city.strip()) and hide_address is not True:
236
+ problems.append(
237
+ "city is required for countries with city-level addressing "
238
+ "(omit only for service-area businesses with hide_address=True)"
239
+ )
240
+ if problems:
241
+ from listingsapi.exceptions import ValidationError
242
+
243
+ raise ValidationError("Invalid location: " + "; ".join(problems))
244
+
245
+ input: dict[str, Any] = {
246
+ "name": name.strip(),
247
+ "description": description.strip(),
248
+ "subCategoryId": sub_category_id,
249
+ "countryIso": country_iso,
250
+ }
251
+ optional = {
252
+ "city": city,
253
+ "street": street,
254
+ "stateIso": state_iso,
255
+ "postalCode": postal_code,
256
+ "phone": phone,
257
+ "website": website,
258
+ "storeId": store_id,
259
+ "hideAddress": hide_address,
260
+ "businessHours": business_hours,
261
+ "folderIds": folder_ids,
262
+ "serviceArea": service_area,
263
+ "placeActionLinks": place_action_links,
264
+ "enabledSiteIds": enabled_site_ids,
265
+ }
266
+ input.update({k: v for k, v in optional.items() if v is not None})
267
+ if additional_fields:
268
+ input.update(additional_fields)
269
+
270
+ return self.create(input)
271
+
272
+ def update(self, input: dict[str, Any]) -> APIObject:
273
+ """Update a location. Pass id plus fields to change."""
274
+ if "id" in input:
275
+ input = {**input, "id": encode_location_id(input["id"])}
276
+ data = self._post("locations/update", {"input": input})
277
+ return APIObject(data.get("data", {}).get("updateLocation") or {})
278
+
279
+ def archive(self, location_ids: list[str | int]) -> APIObject:
280
+ """Archive one or more locations."""
281
+ encoded = [encode_location_id(lid) for lid in location_ids]
282
+ data = self._post("locations/archive", {"input": {"locationIds": encoded}})
283
+ return APIObject(data.get("data", {}).get("archiveLocations") or {})
284
+
285
+
286
+ def cancel_archive(
287
+ self, location_ids: list[str | int], selection_type: str, changed_by: str
288
+ ) -> APIObject:
289
+ """Cancel a scheduled archival."""
290
+ encoded = [encode_location_id(lid) for lid in location_ids]
291
+ data = self._post(
292
+ "locations/cancel_archive",
293
+ {"input": {"locationIds": encoded, "selectionType": selection_type, "changedBy": changed_by}},
294
+ )
295
+ return APIObject(data.get("data", {}).get("cancelLocationsArchive") or {})
296
+
@@ -0,0 +1,53 @@
1
+ """Photos resource — client.photos.*"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from listingsapi._types import APIObject
8
+ from listingsapi._utils import encode_location_id
9
+ from listingsapi.resources._base import APIResource
10
+
11
+
12
+ class Photos(APIResource):
13
+ """Manage location photos.
14
+
15
+ Example:
16
+ photos = client.photos.list(16808)
17
+ client.photos.add(16808, [{"photo": "https://example.com/img.jpg", "type": "ADDITIONAL"}])
18
+ """
19
+
20
+ def list(self, location_id: str | int) -> list[APIObject]:
21
+ """Get photos attached to a location."""
22
+ data = self._location_get(location_id, "photos")
23
+ items = data.get("data", {}).get("mediaFilesOfLocation") or []
24
+ return [APIObject(item) for item in items]
25
+
26
+ def add(self, location_id: str | int, photos: list[dict[str, Any]]) -> APIObject:
27
+ """Add photos to a location. Each item needs 'photo' (URL) and 'type' (LOGO, COVER, ADDITIONAL)."""
28
+ data = self._post(
29
+ "locations/photos",
30
+ {"input": {"locationId": encode_location_id(location_id), "photos": photos}},
31
+ )
32
+ return APIObject(data.get("data", {}).get("addLocationPhotos") or {})
33
+
34
+ def remove(self, location_id: str | int, photo_ids: list[str]) -> APIObject:
35
+ """Remove photos from a location."""
36
+ data = self._post(
37
+ "locations/photos/remove",
38
+ {"input": {"locationId": encode_location_id(location_id), "photoIds": photo_ids}},
39
+ )
40
+ return APIObject(data.get("data", {}).get("removeLocationPhotos") or {})
41
+
42
+ def star(self, location_id: str | int, media_ids: list[str], *, starred: bool = True) -> APIObject:
43
+ """Star or unstar photos. Max 4 starred photos per account."""
44
+ data = self._post(
45
+ "locations/photos/star",
46
+ {"input": {"locationId": encode_location_id(location_id), "mediaIds": media_ids, "starred": starred}},
47
+ )
48
+ return APIObject(data.get("data", {}).get("starUnstarLocationPhotos") or {})
49
+
50
+ def upload_status(self, request_id: str) -> APIObject:
51
+ """Get processing status for a bulk photo upload."""
52
+ data = self._get(f"locations/photos/requests/{request_id}")
53
+ return APIObject(data.get("data", {}).get("bulkImageProcessingStatus") or {})