pushframe 5.0.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.
Files changed (49) hide show
  1. pushframe/__init__.py +4 -0
  2. pushframe/api/__init__.py +0 -0
  3. pushframe/api/accountApi.py +72 -0
  4. pushframe/api/activityApi.py +87 -0
  5. pushframe/api/assetApi.py +194 -0
  6. pushframe/api/baseApi.py +6 -0
  7. pushframe/api/frameApi.py +271 -0
  8. pushframe/api/notificationApi.py +15 -0
  9. pushframe/api/peopleApi.py +25 -0
  10. pushframe/api/playlistApi.py +9 -0
  11. pushframe/aura.py +182 -0
  12. pushframe/aws/__init__.py +0 -0
  13. pushframe/aws/awsclient.py +23 -0
  14. pushframe/aws/s3client.py +40 -0
  15. pushframe/aws/sqsclient.py +33 -0
  16. pushframe/cache.py +50 -0
  17. pushframe/cli.py +1134 -0
  18. pushframe/client.py +267 -0
  19. pushframe/exif.py +147 -0
  20. pushframe/export.py +53 -0
  21. pushframe/google/__init__.py +43 -0
  22. pushframe/google/bootstrap.py +134 -0
  23. pushframe/google/cache.py +167 -0
  24. pushframe/google/client.py +140 -0
  25. pushframe/google/enumerate.py +270 -0
  26. pushframe/google/manifest.py +111 -0
  27. pushframe/google/parsers.py +345 -0
  28. pushframe/google/redaction.py +33 -0
  29. pushframe/google/vault.py +126 -0
  30. pushframe/gsync.py +463 -0
  31. pushframe/migration.py +86 -0
  32. pushframe/models/__init__.py +0 -0
  33. pushframe/models/activity.py +79 -0
  34. pushframe/models/asset.py +159 -0
  35. pushframe/models/frame.py +105 -0
  36. pushframe/models/meta.py +11 -0
  37. pushframe/models/person.py +24 -0
  38. pushframe/models/user.py +22 -0
  39. pushframe/ratelimit.py +222 -0
  40. pushframe/reconcile.py +384 -0
  41. pushframe/sync.py +1105 -0
  42. pushframe/utils/dt.py +15 -0
  43. pushframe/utils/io.py +23 -0
  44. pushframe/utils/settings.py +59 -0
  45. pushframe-5.0.0.dist-info/METADATA +53 -0
  46. pushframe-5.0.0.dist-info/RECORD +49 -0
  47. pushframe-5.0.0.dist-info/WHEEL +4 -0
  48. pushframe-5.0.0.dist-info/entry_points.txt +2 -0
  49. pushframe-5.0.0.dist-info/licenses/LICENSE +31 -0
pushframe/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ # pushframe — unofficial community CLI for Aura Frames (not affiliated).
2
+ # Version single source for the CLI surface (REL-02); pyproject keeps the
3
+ # packaging metadata version and tests/test_version.py enforces lockstep.
4
+ __version__ = "5.0.0"
File without changes
@@ -0,0 +1,72 @@
1
+ from pushframe.api.baseApi import BaseApi
2
+ from pushframe.models.user import User
3
+ from pushframe.utils import settings
4
+
5
+
6
+ class AccountApi(BaseApi):
7
+
8
+ def login(self, email: str, password: str) -> User:
9
+ """
10
+ Authenticates with the API.
11
+
12
+ :param email: Registered email
13
+ :param password: Registered password (plaintext)
14
+ :return: Hydrated user object
15
+ """
16
+ login_payload = {
17
+ 'user': {
18
+ 'email': email,
19
+ 'password': password
20
+ },
21
+ 'locale': settings.LOCALE,
22
+ 'app_identifier': settings.AURA_APP_IDENTIFIER,
23
+ 'identifier_for_vendor': settings.DEVICE_IDENTIFIER,
24
+ 'client_device_id': settings.DEVICE_IDENTIFIER
25
+ }
26
+
27
+ json_response = self._client.post('/login.json', login_payload)
28
+ if json_response.get('error') or not json_response.get('result'):
29
+ # A drifted/failed login must surface loudly rather than silently
30
+ # hydrating a model from an empty/error response (D-06).
31
+ raise RuntimeError(
32
+ f"Aura login failed: {json_response.get('message') or json_response.get('error') or 'no result returned'}"
33
+ )
34
+
35
+ return User(**json_response.get('result').get('current_user'))
36
+
37
+ def register(self, email: str, password: str, name: str) -> User:
38
+ """
39
+ Registers an account.
40
+
41
+ :param email: Email to register with
42
+ :param password: Password (plaintext) to register with
43
+ :param name: Display name
44
+ :return: Hydrated user object for the registered user.
45
+ """
46
+ register_payload = {
47
+ 'email': email,
48
+ 'name': name,
49
+ 'password': password,
50
+ 'identifier_for_vendor': settings.DEVICE_IDENTIFIER,
51
+ 'smart_suggestions_off': True,
52
+ 'auto_upload_off': True,
53
+ 'locale': settings.LOCALE,
54
+ 'client_device_id': settings.DEVICE_IDENTIFIER
55
+ }
56
+
57
+ json_response = self._client.post('/account/register.json', data=register_payload)
58
+
59
+ if json_response.get('error') or not json_response.get('result'):
60
+ # TODO: Error handling
61
+ pass
62
+
63
+ return User(**json_response.get('result').get('current_user'))
64
+
65
+ def delete(self) -> bool:
66
+ """
67
+ Deletes the currently logged in user.
68
+ :return: Boolean describing if the user was successfully deleted.
69
+ """
70
+ json_response = self._client.delete(f'/account/delete')
71
+
72
+ return json_response.get('result').get('success') and not json_response.get('error')
@@ -0,0 +1,87 @@
1
+ from pushframe.api.baseApi import BaseApi
2
+
3
+ from pushframe.models.activity import Activity, Comment
4
+ from pushframe.models.asset import Asset, AssetSetting
5
+ from pushframe.models.user import User
6
+
7
+
8
+ class ActivityApi(BaseApi):
9
+
10
+ def get_comments(self, activity_id: str) -> tuple[list[Comment], int, list[User]]:
11
+ """
12
+ Gets all comments on an activity.
13
+
14
+ :param activity_id: Activity id to retrieve comments
15
+ :return: A list of comments, the number of new (unseen)
16
+ comments, and a list of user data associated to the comments.
17
+ """
18
+ json_response = self._client.get(f'/activities/{activity_id}/comments.json')
19
+ return (
20
+ [Comment(**json_comment) for json_comment in json_response.get('comments')],
21
+ json_response.get('new_count'),
22
+ [User(**json_user) for json_user in json_response.get('users')]
23
+ )
24
+
25
+ def create_comment(self, activity_id: str, content: str) -> tuple[Activity, Comment]:
26
+ """
27
+ Creates a comment on an activity.
28
+ :param activity_id: Activity id
29
+ :param content: The text content of the comment.
30
+ :return: The hydrated activity and the hydrated comment.
31
+ """
32
+ json_response = self._client.post(f'/activities/{activity_id}/create_comment.json', data={'content': content})
33
+
34
+ return Activity(**json_response.get('activity')), Comment(**json_response.get('comment'))
35
+
36
+ def remove_comment(self, activity_id: str, comment_id: str):
37
+ """
38
+ Removes a comment from an activity.
39
+ :param activity_id: Activity id
40
+ :param comment_id: Comment id associated to the activity
41
+ :return: The hydrated activity with the comment removed.
42
+ """
43
+ json_response = self._client.post(f'/activities/{activity_id}/remove_comment.json',
44
+ data={'comment_id': comment_id})
45
+
46
+ return Activity(**json_response.get('activity'))
47
+
48
+ def get_activity_assets(self, activity_id: str, limit: int = 1000, cursor: str = None):
49
+ """
50
+ Gets assets associated to an activity. The results are paginated with `limit` results per page. To obtain the next set
51
+ of pages, pass in the cursor from the response.
52
+
53
+ TODO: The API doesn't seem to produce a cursor.
54
+
55
+ :param activity_id: Activity id
56
+ :param limit: Maximum number of assets per page / callout.
57
+ :param cursor: The cursor from the previous page.
58
+ :return: A list of assets and a list of asset settings.
59
+ """
60
+ json_response = self._client.get(f'/activities/{activity_id}/assets.json',
61
+ query_params={'limit': limit, 'cursor': cursor})
62
+ return (
63
+ [Asset(**json_asset) for json_asset in json_response.get('assets')],
64
+ [AssetSetting(**json_asset_setting) for json_asset_setting in json_response.get('asset_settings')]
65
+ )
66
+
67
+ def post_activity(self, activity_id: str, frame_id: str, data: dict):
68
+ """
69
+ TODO: Unknown
70
+ :param activity_id:
71
+ :param frame_id:
72
+ :param data:
73
+ :return:
74
+ """
75
+ json_response = self._client.post(f'/activities/{activity_id}/copy.json', data=data,
76
+ query_params={'frame_id': frame_id})
77
+ return json_response
78
+
79
+ def delete_activity(self, activity_id: str) -> None:
80
+ """
81
+ Deletes the activity. TODO: Better description
82
+ :param activity_id: Activity to remove
83
+ """
84
+ self._client.delete(f'/activities/{activity_id}')
85
+
86
+ # Response is typically an empty JSON object.
87
+ return None
@@ -0,0 +1,194 @@
1
+ import typing
2
+
3
+ from loguru import logger
4
+ from pydantic import ValidationError
5
+
6
+ from pushframe.api.baseApi import BaseApi
7
+ from pushframe.client import WriteEndpointError
8
+
9
+ # TODO: Untested
10
+ from pushframe.models.asset import Asset, AssetPartial, AssetPartialId
11
+
12
+
13
+ class BatchUpdateResult(typing.NamedTuple):
14
+ """Result of `AssetApi.batch_update`.
15
+
16
+ A `NamedTuple` so `unacknowledged` is reachable by name
17
+ (`result.unacknowledged`) while the value stays usable positionally.
18
+
19
+ `unacknowledged` is the sent-but-not-acknowledged local_identifier set --
20
+ the honest name for the fact that a `batch_update` response can silently
21
+ drop requested ids (REL-07, D-18). A non-empty `unacknowledged` is a
22
+ per-item failure signal for the caller to attribute, NOT an error
23
+ condition for `batch_update` itself to raise on -- see the docstring
24
+ below.
25
+ """
26
+ ids: list[str]
27
+ successes: list[AssetPartialId]
28
+ unacknowledged: list[str]
29
+
30
+
31
+ class AssetApi(BaseApi):
32
+
33
+ def batch_update(self, assets: Asset | AssetPartial | list[Asset | AssetPartial]) -> BatchUpdateResult:
34
+ """
35
+ Posts new metadata to the API for one or more assets. This does not appear to affect the
36
+ frame; however subsequent calls to retrieve the asset(s) will have the modified metadata.
37
+
38
+ Primarily used to update an asset after the image has been uploaded to S3.
39
+
40
+ This is a native Pushd BATCH endpoint: the official app sends the whole collection of
41
+ assets to update in a single `{"assets": [...]}` call rather than one call per asset. A
42
+ single `Asset`/`AssetPartial` is accepted for backward compatibility (normalized to a
43
+ one-element list).
44
+
45
+ `successes` in the response (each carrying `id` + `local_identifier`) is the per-file
46
+ source of truth for batch callers: match each sent item's `local_identifier` against
47
+ `successes[].local_identifier` to attribute success/failure per item -- a partial
48
+ `successes` list (fewer entries than sent) is the NORMAL, expected signal in batch mode
49
+ that the caller must attribute per-item, not an error to raise on. Only the `error`
50
+ envelope (a whole-call failure) raises here.
51
+
52
+ The returned `BatchUpdateResult.unacknowledged` is that same per-item signal computed
53
+ once, here, as the sent local_identifiers (in sent order) that never appear among the
54
+ parsed `successes` entries -- returned to the caller rather than raised, since a partial
55
+ `successes` list is this endpoint's normal batch behavior, not an error. Every caller,
56
+ not just `execute_plan`, can read it -- `Aura.upload_image` now does too.
57
+
58
+ Each entry in the inbound `successes` array is parsed strictly (`AssetPartialId`'s
59
+ cross-field validator still applies), but a single malformed entry is skipped and logged
60
+ rather than raised (D-19): the API is undocumented and its shape drifts, so one junk row
61
+ in an otherwise-good response must not cost the whole chunk's per-file attribution.
62
+
63
+ :param assets: A single `Asset`/`AssetPartial`, or a list of them, to update in one call.
64
+ :return: A `BatchUpdateResult` of sent remote ids, parsed `AssetPartialId` successes (may
65
+ be a partial subset of what was sent -- see above), and the unacknowledged
66
+ local_identifier set.
67
+ """
68
+ items = assets if isinstance(assets, list) else [assets]
69
+
70
+ json_response = self._client.put(f'/assets/batch_update.json', data={
71
+ "assets": [
72
+ item.dict(
73
+ include={
74
+ 'data_uti': True,
75
+ 'favorite': True,
76
+ 'file_name': True,
77
+ 'height': True,
78
+ 'local_identifier': True,
79
+ 'location': True,
80
+ 'md5_hash': True,
81
+ 'modified_at': True,
82
+ 'orientation': True,
83
+ 'selected': True,
84
+ 'taken_at': True,
85
+ 'upload_priority': True,
86
+ 'width': True
87
+ })
88
+ for item in items
89
+ ]
90
+ })
91
+ if json_response.get('error'):
92
+ raise WriteEndpointError(f"batch_update failed: {json_response.get('error')}")
93
+
94
+ ids = json_response.get('ids') or []
95
+ raw_successes = json_response.get('successes') or []
96
+
97
+ successes: list[AssetPartialId] = []
98
+ for entry in raw_successes:
99
+ # D-19: strict outbound, tolerant inbound. The outbound
100
+ # AssetPartialId validator (pushframe/models/asset.py) is left
101
+ # untouched; here on the inbound side, one malformed row must
102
+ # not crash the whole chunk -- the API is undocumented and its
103
+ # shape drifts (Phase 10's smart_adds regression is the
104
+ # precedent), so a single junk entry in a 50-item response is
105
+ # skipped and logged rather than costing the whole chunk's
106
+ # per-file attribution through execute_plan's generic
107
+ # `except Exception` branch.
108
+ try:
109
+ successes.append(AssetPartialId(**entry))
110
+ except ValidationError as e:
111
+ keys = sorted(entry.keys()) if isinstance(entry, dict) else type(entry).__name__
112
+ # T-11-06: pydantic v2's default ValidationError str/repr embeds
113
+ # each error's raw input_value (confirmed: constructing
114
+ # AssetPartialId(**{'user_id': '...'}) puts the whole dict --
115
+ # including user_id -- in str(e)). include_input=False strips
116
+ # that before it reaches the log sink; only keys + error
117
+ # type/message are logged, never entry values.
118
+ safe_errors = e.errors(include_url=False, include_input=False)
119
+ logger.warning(f"batch_update: skipping malformed successes entry (keys={keys}): {safe_errors}")
120
+
121
+ acknowledged = {s.local_identifier for s in successes if s.local_identifier}
122
+ unacknowledged = [
123
+ item.local_identifier for item in items
124
+ if item.local_identifier and item.local_identifier not in acknowledged
125
+ ]
126
+
127
+ return BatchUpdateResult(ids, successes, unacknowledged)
128
+
129
+ def get_asset_by_local_identifier(self, local_id: str):
130
+ """
131
+ Retrieves an asset given a local id.
132
+ :param local_id: A local id string.
133
+ :return: The retrieved asset, related child albums, and any smart adds related to the asset.
134
+ """
135
+ json_response = self._client.get(f'/assets/asset_for_local_identifier.json',
136
+ query_params={'local_identifier': local_id})
137
+
138
+ return Asset(**json_response.get('asset')), json_response.get('child_albums'), json_response.get('smart_adds')
139
+
140
+ def update_taken_at_date(self, asset: Asset) -> Asset:
141
+ """
142
+ Updates an asset's taken_date and taken_at_granularity. This will modify the date displayed in the frame and
143
+ from future responses.
144
+ :param asset: Asset with new taken_at or taken_at_granularity
145
+ :return: The asset with modified dates
146
+ """
147
+ # Asset.id is a required str (never None for a server-hydrated
148
+ # Asset), so this always uses the id-based request shape.
149
+ request = {
150
+ 'taken_at': asset.taken_at,
151
+ 'taken_at_granularity': asset.taken_at_granularity,
152
+ 'id': asset.id,
153
+ }
154
+
155
+ json_response = self._client.post(f'/assets/update_taken_at_date.json', data=request)
156
+ return Asset(**json_response)
157
+
158
+ def delete_asset(self, asset: Asset):
159
+ """
160
+ Deletes the asset. **Currently unknown if this is used, most deletions occur by removing
161
+ the activity; maybe this deletes it from S3/Glacier** see :func:`FrameApi.remove_asset`
162
+
163
+ :param asset: Asset for removal
164
+ :return: TODO
165
+ """
166
+ # Asset.id is a required str (never None for a server-hydrated
167
+ # Asset), so this always uses the id-based delete endpoint.
168
+ json_response = self._client.delete(f'/assets/{asset.id}.json')
169
+
170
+ if json_response.get('error'):
171
+ raise WriteEndpointError(f"delete_asset failed: {json_response.get('error')}")
172
+
173
+ return json_response
174
+
175
+ def crop_asset(self, asset: Asset) -> Asset:
176
+ """
177
+ Crops an asset, modifying `rotation_cw`, `user_landscape_rect`, `user_portrait_rect` and related
178
+ aspect ratio rects.
179
+ :param asset: Asset containing new rotation/rect data.
180
+ :return: The asset with modified crop fields.
181
+ """
182
+ json_response = self._client.post(f'/assets/crop.json', data=asset.dict(
183
+ include={
184
+ 'id': True,
185
+ 'local_identifier': True,
186
+ 'user_id': True,
187
+ 'rotation_cw': True,
188
+ 'user_landscape_16_10_rect': True,
189
+ 'user_landscape_rect': True,
190
+ 'user_portrait_4_5_rect': True,
191
+ 'user_portrait_rect': True
192
+ }))
193
+
194
+ return Asset(**json_response.get('asset'))
@@ -0,0 +1,6 @@
1
+ from pushframe.client import Client
2
+
3
+
4
+ class BaseApi:
5
+ def __init__(self, client: Client):
6
+ self._client = client
@@ -0,0 +1,271 @@
1
+ import uuid
2
+
3
+ from pushframe.api.baseApi import BaseApi
4
+ from pushframe.models.activity import Activity
5
+ from pushframe.models.asset import Asset, AssetPartialId
6
+ from pushframe.models.frame import Frame, FramePartial
7
+
8
+ from pushframe.utils.dt import get_utc_now, format_dt_to_aura
9
+
10
+
11
+ def _apply_asset_settings(assets: list[Asset], asset_settings) -> None:
12
+ """Overwrite each asset's `selected` with this frame's visibility, taken
13
+ from the `asset_settings` array that rides alongside `assets` in the
14
+ /frames/{id}/assets.json response.
15
+
16
+ Visibility is per-frame state, so it lives in `asset_settings` (keyed by
17
+ `asset_id`, carrying `selected` and its mirror `hidden`), NOT on the asset
18
+ itself. Live-confirmed in Phase 10: after `exclude_asset` hid a photo, its
19
+ `asset_settings.selected` flipped to `false` while the asset-level
20
+ `selected` stayed `true`. Reading the asset-level field would classify
21
+ every photo as visible forever and silently never hide anything, so the
22
+ join happens once here at the API boundary rather than in each caller.
23
+
24
+ An asset with no matching settings row keeps whatever `selected` the API
25
+ sent (it has no per-frame override to apply). Mutates `assets` in place.
26
+ """
27
+ if not asset_settings:
28
+ return
29
+
30
+ visibility = {
31
+ row['asset_id']: row['selected']
32
+ for row in asset_settings
33
+ if row.get('asset_id') is not None and row.get('selected') is not None
34
+ }
35
+ for asset in assets:
36
+ if asset.id in visibility:
37
+ asset.selected = visibility[asset.id]
38
+
39
+
40
+ class FrameApi(BaseApi):
41
+
42
+ def get_frames(self) -> list[Frame]:
43
+ """
44
+ Gets all frames available for the active user.
45
+ :return: List of all frames the active user owns or is collaborating on.
46
+ """
47
+ json_response = self._client.get('/frames.json')
48
+ return [Frame(**frame_data) for frame_data in json_response.get('frames')]
49
+
50
+ def get_frame(self, frame_id: str) -> tuple[Frame, int]:
51
+ """
52
+ Gets frame data for a given `frame_id`
53
+ :param frame_id: Frame id to retrieve
54
+ :return: The hydrated frame and the frame's total asset count.
55
+ """
56
+ json_response = self._client.get(f'/frames/{frame_id}.json')
57
+ frame_data = json_response.get('frame')
58
+ # The live API moved the asset count: it used to be the top-level
59
+ # `total_asset_count` and is now `frame.num_assets` (Phase 2 live drift).
60
+ # Prefer the legacy key, fall back to the new location so either API
61
+ # shape yields a count.
62
+ total_asset_count = json_response.get('total_asset_count')
63
+ if total_asset_count is None and frame_data:
64
+ total_asset_count = frame_data.get('num_assets')
65
+ return Frame(**frame_data), total_asset_count
66
+
67
+ def get_assets(self, frame_id: str, limit: int = 1000, cursor: str = None) -> tuple[list[Asset], str]:
68
+ """
69
+ Gets assets for a `frame_id`. The results are paginated with `limit` results per page. To obtain the next set
70
+ of pages, pass in the cursor from the response.
71
+
72
+ Returns BOTH visible and hidden assets: the request sends `filter=all`
73
+ because the server otherwise defaults to `filter=selected` and silently
74
+ drops every hidden asset from the page (live-confirmed Phase 10 --
75
+ omitting the filter returned 154 assets where `filter=all` returned
76
+ 157). Hidden assets must stay in the listing so a hidden photo still
77
+ counts as present for md5 dedup and is never re-uploaded (D-06).
78
+
79
+ Each returned `Asset.selected` carries THIS FRAME's visibility, joined
80
+ from the response's parallel `asset_settings` array (see
81
+ `_apply_asset_settings`). The asset-level `selected` field the API
82
+ sends is NOT per-frame visibility -- live probing showed it stays
83
+ `true` even while the photo is hidden on the frame -- so it is
84
+ overwritten here, at the boundary, and every downstream consumer can
85
+ read `asset.selected` as the real signal (D-01/D-05).
86
+
87
+ :param frame_id: Frame ID to retrieve assets
88
+ :param limit: Maximum number of assets per page / callout.
89
+ :param cursor: The cursor from the previous page.
90
+ :return: List of all the assets (visible and hidden), and the next page's cursor
91
+ (will be `None` if there are no more pages)
92
+ """
93
+ json_response = self._client.get(f'/frames/{frame_id}/assets.json',
94
+ query_params={'limit': limit, 'cursor': cursor, 'filter': 'all'})
95
+ if json_response.get('error'):
96
+ # Surface API drift instead of silently swallowing it (D-06):
97
+ # a drifted/failed asset page must not be processed as success.
98
+ raise RuntimeError(
99
+ f"get_assets failed for frame {frame_id}: "
100
+ f"{json_response.get('message') or json_response.get('error')}"
101
+ )
102
+ assets = [Asset(**asset_data) for asset_data in json_response.get('assets')]
103
+ _apply_asset_settings(assets, json_response.get('asset_settings'))
104
+ return assets, json_response.get('next_page_cursor')
105
+
106
+ def get_activities(self, frame_id: str, cursor: str = None):
107
+ """
108
+ Gets activities associated to a frame. This appears to be paginated, although
109
+ :param frame_id: Frame id to retrieve associated activities
110
+ :param cursor: Cursor of the previous page TODO: **UNUSED?**
111
+ :return: A list of activities, the cursor for the next page
112
+ """
113
+ json_response = self._client.get(f'/frames/{frame_id}/activities.json', query_params={'cursor': cursor})
114
+ return [Activity(**json_activity) for json_activity in
115
+ json_response.get('activities')], json_response.get('next_page_cursor')
116
+
117
+ def show_asset(self, frame_id: str, asset_id: str, goto_time: str) -> bool:
118
+ """
119
+ Forces the frame to display the asset.
120
+
121
+ :param frame_id: Frame id to control
122
+ :param asset_id: Asset id to display on the frame
123
+ :param goto_time: TODO: Unknown, appears to be the current datetime -- does setting it to the future queue the
124
+ asset?
125
+ :return: Boolean describing if the frame was able to process the request.
126
+ """
127
+ json_response = self._client.post(f'/frames/{frame_id}/goto.json', data={
128
+ 'asset_id': asset_id,
129
+ 'frame_id': frame_id,
130
+ 'goto_time': goto_time if goto_time else format_dt_to_aura(get_utc_now()),
131
+ 'swipe_direction': 0,
132
+ 'impression_id': uuid.uuid4(),
133
+ 'select_asset': True
134
+ })
135
+
136
+ return json_response.get('showing')
137
+
138
+ def update_frame(self, frame_id: str, frame_partial: FramePartial):
139
+ """
140
+ Updates a frame by id. This cannot update the frame id.
141
+ TODO: Should we assume that FramePartial has `id` set and use that instead of `frame_id`?
142
+
143
+ :param frame_id: Frame to update
144
+ :param frame_partial: `FramePartial` containing changes to the frame.
145
+ :return: Returns the hydrated frame with changes.
146
+ """
147
+ json_response = self._client.put(f'/frames/{frame_id}.json',
148
+ data={'frame': frame_partial.dict(exclude_unset=True)})
149
+ return Frame(**json_response.get('frame'))
150
+
151
+ def select_asset(self, frame_id: str, asset_partial_ids: AssetPartialId | list[AssetPartialId]) -> int:
152
+ """
153
+ Associates one or more assets to a frame. This is typically done immediately before the
154
+ asset(s) are uploaded to S3.
155
+
156
+ This is a native Pushd BATCH endpoint: the official app sends the whole collection of
157
+ assets to associate in a single `{"assets": [...]}` call rather than one call per asset.
158
+ A single `AssetPartialId` is accepted for backward compatibility (normalized to a
159
+ one-element list) and legacy single-item callers are unaffected.
160
+
161
+ :param frame_id: Frame id
162
+ :param asset_partial_ids: A single `AssetPartialId`, or a list of them, to associate to
163
+ the frame in one call.
164
+ :return: The number of assets that failed to be associated to the frame. NOTE: this is a
165
+ count only -- in batch mode (a list of more than one item) there is no per-item
166
+ signal in this response, so a caller cannot learn WHICH item(s) failed from
167
+ select_asset alone.
168
+ """
169
+ items = asset_partial_ids if isinstance(asset_partial_ids, list) else [asset_partial_ids]
170
+
171
+ json_response = self._client.post(f'/frames/{frame_id}/select_asset.json',
172
+ data={'assets': [item.to_request_format() for item in items]})
173
+ if json_response.get('error'):
174
+ raise RuntimeError(f"select_asset failed for frame {frame_id}: {json_response.get('error')}")
175
+
176
+ number_failed = json_response.get('number_failed')
177
+ if number_failed:
178
+ raise RuntimeError(f"select_asset reported {number_failed} failure(s) for frame {frame_id}")
179
+
180
+ return number_failed
181
+
182
+ def exclude_asset(self, frame_id: str, asset_partial_ids: AssetPartialId | list[AssetPartialId]) -> int:
183
+ """
184
+ Hides one or more assets on the frame: they stop displaying in the slideshow but are
185
+ NOT deleted -- they remain in `get_assets(filter='all')` and still show in the app.
186
+ Live-confirmed in Phase 10 (the frame's asset total was unchanged across a hide, and
187
+ the asset's `asset_settings.selected` flipped to false / `hidden` to true).
188
+
189
+ `select_asset` is the exact inverse -- it un-hides. There is no `include_asset`
190
+ endpoint and none is needed.
191
+
192
+ This is a native Pushd BATCH endpoint (service method `excludeAssets`): the official
193
+ app sends the whole collection in a single `{"assets": [...]}` call rather than one
194
+ call per asset, live-confirmed in Phase 10 by hiding two assets in one request. A
195
+ single `AssetPartialId` is accepted for backward compatibility (normalized to a
196
+ one-element list).
197
+
198
+ The URL deliberately has NO `.json` suffix -- unlike every sibling endpoint here.
199
+ That matches what the decompiled app posts and is live-confirmed working; it is not
200
+ a bug, so do not "fix" it.
201
+
202
+ :param frame_id: Frame id
203
+ :param asset_partial_ids: A single `AssetPartialId`, or a list of them, to hide on
204
+ the frame in one call.
205
+ :return: The number of assets that failed to be hidden. NOTE: this is a count only --
206
+ in batch mode there is no per-item signal in this response, so a caller cannot
207
+ learn WHICH item(s) failed from exclude_asset alone.
208
+ """
209
+ items = asset_partial_ids if isinstance(asset_partial_ids, list) else [asset_partial_ids]
210
+
211
+ json_response = self._client.post(f'/frames/{frame_id}/exclude_asset',
212
+ data={'assets': [item.to_request_format() for item in items]})
213
+ if json_response.get('error'):
214
+ raise RuntimeError(f"exclude_asset failed for frame {frame_id}: {json_response.get('error')}")
215
+
216
+ number_failed = json_response.get('number_failed')
217
+ if number_failed:
218
+ raise RuntimeError(f"exclude_asset reported {number_failed} failure(s) for frame {frame_id}")
219
+
220
+ return number_failed
221
+
222
+ def remove_asset(self, frame_id: str, asset_partial_ids: AssetPartialId | list[AssetPartialId]) -> int:
223
+ """
224
+ Disassociates one or more assets from a frame. This does not seem to remove the asset(s)
225
+ from S3/Glacier.
226
+
227
+ This is a native Pushd BATCH endpoint: the official app sends the whole collection of
228
+ assets to remove in a single `{"assets": [...]}` call rather than one call per asset. A
229
+ single `AssetPartialId` is accepted for backward compatibility (normalized to a
230
+ one-element list). Per-item delete attribution degrades to per-chunk in batch mode: a
231
+ nonzero `number_failed` or a raised error fails the WHOLE batch's deletes, since this
232
+ endpoint returns only a count, never which item(s) failed.
233
+
234
+ :param frame_id: Frame id containing the asset(s).
235
+ :param asset_partial_ids: A single `AssetPartialId`, or a list of them, to remove from
236
+ the frame in one call.
237
+ :return: The number of assets that failed to be removed from the frame. NOTE: this is a
238
+ count only -- there is no per-item signal in this response.
239
+ """
240
+ items = asset_partial_ids if isinstance(asset_partial_ids, list) else [asset_partial_ids]
241
+
242
+ json_response = self._client.post(f'/frames/{frame_id}/remove_asset.json',
243
+ data={'assets': [item.to_request_format() for item in items]})
244
+ if json_response.get('error'):
245
+ raise RuntimeError(f"remove_asset failed for frame {frame_id}: {json_response.get('error')}")
246
+
247
+ number_failed = json_response.get('number_failed')
248
+ if number_failed:
249
+ raise RuntimeError(f"remove_asset reported {number_failed} failure(s) for frame {frame_id}")
250
+
251
+ return number_failed
252
+
253
+ def reconfigure(self, frame_id: str):
254
+ """
255
+ TODO: Unknown
256
+ :param frame_id:
257
+ :return:
258
+ """
259
+ return self._client.post(f'/frames/{frame_id}/reconfigure.json', data=None)
260
+
261
+ def add_playlist(self, frame_id: str, playlist_params: any):
262
+ # TODO: Implement
263
+ json_response = self._client.post(f'/frames/{frame_id}/add_playlist.json', data={})
264
+
265
+ return json_response
266
+
267
+ def remove_playlist(self, frame_id: str, playlist_params: any):
268
+ # TODO: Implement
269
+ json_response = self._client.post(f'/frames/{frame_id}/remove_playlist.json', data={})
270
+
271
+ return json_response
@@ -0,0 +1,15 @@
1
+ from pushframe.api.baseApi import BaseApi
2
+
3
+
4
+ # TODO: Test
5
+ class NotificationAPI(BaseApi):
6
+
7
+ def get_notification_settings(self):
8
+ json_response = self._client.get('f/notifications/settings/')
9
+
10
+ return json_response
11
+
12
+ def update_notification(self, update_settings: any):
13
+ json_response = self._client.post(f'/notifications/update_setting', data=update_settings)
14
+
15
+ return json_response