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.
- pushframe/__init__.py +4 -0
- pushframe/api/__init__.py +0 -0
- pushframe/api/accountApi.py +72 -0
- pushframe/api/activityApi.py +87 -0
- pushframe/api/assetApi.py +194 -0
- pushframe/api/baseApi.py +6 -0
- pushframe/api/frameApi.py +271 -0
- pushframe/api/notificationApi.py +15 -0
- pushframe/api/peopleApi.py +25 -0
- pushframe/api/playlistApi.py +9 -0
- pushframe/aura.py +182 -0
- pushframe/aws/__init__.py +0 -0
- pushframe/aws/awsclient.py +23 -0
- pushframe/aws/s3client.py +40 -0
- pushframe/aws/sqsclient.py +33 -0
- pushframe/cache.py +50 -0
- pushframe/cli.py +1134 -0
- pushframe/client.py +267 -0
- pushframe/exif.py +147 -0
- pushframe/export.py +53 -0
- pushframe/google/__init__.py +43 -0
- pushframe/google/bootstrap.py +134 -0
- pushframe/google/cache.py +167 -0
- pushframe/google/client.py +140 -0
- pushframe/google/enumerate.py +270 -0
- pushframe/google/manifest.py +111 -0
- pushframe/google/parsers.py +345 -0
- pushframe/google/redaction.py +33 -0
- pushframe/google/vault.py +126 -0
- pushframe/gsync.py +463 -0
- pushframe/migration.py +86 -0
- pushframe/models/__init__.py +0 -0
- pushframe/models/activity.py +79 -0
- pushframe/models/asset.py +159 -0
- pushframe/models/frame.py +105 -0
- pushframe/models/meta.py +11 -0
- pushframe/models/person.py +24 -0
- pushframe/models/user.py +22 -0
- pushframe/ratelimit.py +222 -0
- pushframe/reconcile.py +384 -0
- pushframe/sync.py +1105 -0
- pushframe/utils/dt.py +15 -0
- pushframe/utils/io.py +23 -0
- pushframe/utils/settings.py +59 -0
- pushframe-5.0.0.dist-info/METADATA +53 -0
- pushframe-5.0.0.dist-info/RECORD +49 -0
- pushframe-5.0.0.dist-info/WHEEL +4 -0
- pushframe-5.0.0.dist-info/entry_points.txt +2 -0
- pushframe-5.0.0.dist-info/licenses/LICENSE +31 -0
pushframe/__init__.py
ADDED
|
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'))
|
pushframe/api/baseApi.py
ADDED
|
@@ -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
|