marqetive-lib 0.1.6__py3-none-any.whl → 0.1.7__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.
- marqetive/__init__.py +13 -7
- marqetive/core/__init__.py +6 -4
- marqetive/core/base.py +92 -13
- marqetive/core/models.py +111 -7
- marqetive/platforms/instagram/__init__.py +2 -1
- marqetive/platforms/instagram/media.py +79 -13
- marqetive/platforms/instagram/models.py +74 -0
- marqetive/platforms/linkedin/__init__.py +51 -2
- marqetive/platforms/linkedin/client.py +978 -94
- marqetive/platforms/linkedin/media.py +156 -47
- marqetive/platforms/linkedin/models.py +413 -0
- marqetive/platforms/tiktok/__init__.py +2 -1
- marqetive/platforms/tiktok/media.py +112 -51
- marqetive/platforms/tiktok/models.py +82 -0
- marqetive/platforms/twitter/__init__.py +2 -1
- marqetive/platforms/twitter/client.py +86 -0
- marqetive/platforms/twitter/media.py +133 -65
- marqetive/platforms/twitter/models.py +58 -0
- {marqetive_lib-0.1.6.dist-info → marqetive_lib-0.1.7.dist-info}/METADATA +1 -9
- marqetive_lib-0.1.7.dist-info/RECORD +39 -0
- marqetive_lib-0.1.6.dist-info/RECORD +0 -35
- {marqetive_lib-0.1.6.dist-info → marqetive_lib-0.1.7.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""LinkedIn-specific models for the Community Management API.
|
|
2
|
+
|
|
3
|
+
This module defines LinkedIn-specific data models that extend the core models
|
|
4
|
+
for features unique to LinkedIn's Community Management API, including
|
|
5
|
+
reactions, social metadata, and organization management.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ReactionType(StrEnum):
|
|
16
|
+
"""LinkedIn reaction types.
|
|
17
|
+
|
|
18
|
+
These correspond to the UI labels users see when reacting to content.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
LIKE: Standard like reaction
|
|
22
|
+
PRAISE: "Celebrate" reaction
|
|
23
|
+
EMPATHY: "Love" reaction
|
|
24
|
+
INTEREST: "Insightful" reaction
|
|
25
|
+
APPRECIATION: "Support" reaction
|
|
26
|
+
ENTERTAINMENT: "Funny" reaction
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
LIKE = "LIKE"
|
|
30
|
+
PRAISE = "PRAISE" # Celebrate
|
|
31
|
+
EMPATHY = "EMPATHY" # Love
|
|
32
|
+
INTEREST = "INTEREST" # Insightful
|
|
33
|
+
APPRECIATION = "APPRECIATION" # Support
|
|
34
|
+
ENTERTAINMENT = "ENTERTAINMENT" # Funny
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CallToActionLabel(StrEnum):
|
|
38
|
+
"""LinkedIn call-to-action button labels for posts.
|
|
39
|
+
|
|
40
|
+
These can be used with articles and sponsored content to add
|
|
41
|
+
clickable action buttons.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
APPLY = "APPLY"
|
|
45
|
+
DOWNLOAD = "DOWNLOAD"
|
|
46
|
+
VIEW_QUOTE = "VIEW_QUOTE"
|
|
47
|
+
LEARN_MORE = "LEARN_MORE"
|
|
48
|
+
SIGN_UP = "SIGN_UP"
|
|
49
|
+
SUBSCRIBE = "SUBSCRIBE"
|
|
50
|
+
REGISTER = "REGISTER"
|
|
51
|
+
JOIN = "JOIN"
|
|
52
|
+
ATTEND = "ATTEND"
|
|
53
|
+
REQUEST_DEMO = "REQUEST_DEMO"
|
|
54
|
+
SEE_MORE = "SEE_MORE"
|
|
55
|
+
BUY_NOW = "BUY_NOW"
|
|
56
|
+
SHOP_NOW = "SHOP_NOW"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class FeedDistribution(StrEnum):
|
|
60
|
+
"""LinkedIn feed distribution options for posts.
|
|
61
|
+
|
|
62
|
+
Controls how and where posts are distributed in feeds.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
MAIN_FEED = "MAIN_FEED"
|
|
66
|
+
NONE = "NONE"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class PostVisibility(StrEnum):
|
|
70
|
+
"""LinkedIn post visibility options.
|
|
71
|
+
|
|
72
|
+
Controls who can see the post.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
PUBLIC = "PUBLIC"
|
|
76
|
+
CONNECTIONS = "CONNECTIONS"
|
|
77
|
+
LOGGED_IN = "LOGGED_IN"
|
|
78
|
+
CONTAINER = "CONTAINER"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class CommentsState(StrEnum):
|
|
82
|
+
"""State of comments on a LinkedIn post.
|
|
83
|
+
|
|
84
|
+
OPEN: Comments are enabled
|
|
85
|
+
CLOSED: Comments are disabled (deletes existing comments)
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
OPEN = "OPEN"
|
|
89
|
+
CLOSED = "CLOSED"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class OrganizationType(StrEnum):
|
|
93
|
+
"""LinkedIn organization types."""
|
|
94
|
+
|
|
95
|
+
COMPANY = "COMPANY"
|
|
96
|
+
SCHOOL = "SCHOOL"
|
|
97
|
+
GROUP = "GROUP"
|
|
98
|
+
SHOWCASE = "SHOWCASE"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Reaction(BaseModel):
|
|
102
|
+
"""Represents a reaction on a LinkedIn post or comment.
|
|
103
|
+
|
|
104
|
+
Attributes:
|
|
105
|
+
actor: URN of the person or organization that reacted
|
|
106
|
+
entity: URN of the entity (post/comment) that was reacted to
|
|
107
|
+
reaction_type: Type of reaction (LIKE, PRAISE, etc.)
|
|
108
|
+
created_at: Timestamp when the reaction was created
|
|
109
|
+
|
|
110
|
+
Example:
|
|
111
|
+
>>> reaction = Reaction(
|
|
112
|
+
... actor="urn:li:person:abc123",
|
|
113
|
+
... entity="urn:li:share:12345",
|
|
114
|
+
... reaction_type=ReactionType.LIKE
|
|
115
|
+
... )
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
actor: str
|
|
119
|
+
entity: str
|
|
120
|
+
reaction_type: ReactionType
|
|
121
|
+
created_at: datetime | None = None
|
|
122
|
+
raw_data: dict[str, Any] = Field(default_factory=dict)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class ReactionSummary(BaseModel):
|
|
126
|
+
"""Summary of reactions by type.
|
|
127
|
+
|
|
128
|
+
Attributes:
|
|
129
|
+
reaction_type: Type of reaction
|
|
130
|
+
count: Number of reactions of this type
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
reaction_type: ReactionType
|
|
134
|
+
count: int
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class CommentSummary(BaseModel):
|
|
138
|
+
"""Summary of comments on a post.
|
|
139
|
+
|
|
140
|
+
Attributes:
|
|
141
|
+
count: Total number of comments (including replies)
|
|
142
|
+
top_level_count: Number of top-level comments (excluding replies)
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
count: int
|
|
146
|
+
top_level_count: int
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class SocialMetadata(BaseModel):
|
|
150
|
+
"""Social metadata for a LinkedIn post or comment.
|
|
151
|
+
|
|
152
|
+
Contains engagement metrics like reactions and comment counts.
|
|
153
|
+
|
|
154
|
+
Attributes:
|
|
155
|
+
entity: URN of the entity (post/comment)
|
|
156
|
+
reaction_summaries: Dictionary mapping reaction types to counts
|
|
157
|
+
comment_count: Total number of comments
|
|
158
|
+
top_level_comment_count: Number of top-level comments
|
|
159
|
+
comments_state: Whether comments are enabled (OPEN) or disabled (CLOSED)
|
|
160
|
+
|
|
161
|
+
Example:
|
|
162
|
+
>>> metadata = SocialMetadata(
|
|
163
|
+
... entity="urn:li:share:12345",
|
|
164
|
+
... reaction_summaries={ReactionType.LIKE: 10, ReactionType.PRAISE: 5},
|
|
165
|
+
... comment_count=3,
|
|
166
|
+
... top_level_comment_count=2,
|
|
167
|
+
... comments_state=CommentsState.OPEN
|
|
168
|
+
... )
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
entity: str
|
|
172
|
+
reaction_summaries: dict[ReactionType, int] = Field(default_factory=dict)
|
|
173
|
+
comment_count: int = 0
|
|
174
|
+
top_level_comment_count: int = 0
|
|
175
|
+
comments_state: CommentsState = CommentsState.OPEN
|
|
176
|
+
raw_data: dict[str, Any] = Field(default_factory=dict)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class Organization(BaseModel):
|
|
180
|
+
"""Represents a LinkedIn organization (Company Page).
|
|
181
|
+
|
|
182
|
+
Attributes:
|
|
183
|
+
id: Organization URN (e.g., urn:li:organization:12345)
|
|
184
|
+
name: Organization name
|
|
185
|
+
localized_name: Localized organization name
|
|
186
|
+
vanity_name: URL-friendly name (e.g., "linkedin" for linkedin.com/company/linkedin)
|
|
187
|
+
logo_url: URL of the organization's logo
|
|
188
|
+
follower_count: Number of followers
|
|
189
|
+
primary_type: Type of organization (COMPANY, SCHOOL, etc.)
|
|
190
|
+
website_url: Organization's website
|
|
191
|
+
description: Organization description
|
|
192
|
+
industry: Industry category
|
|
193
|
+
|
|
194
|
+
Example:
|
|
195
|
+
>>> org = Organization(
|
|
196
|
+
... id="urn:li:organization:12345",
|
|
197
|
+
... name="Example Corp",
|
|
198
|
+
... localized_name="Example Corp",
|
|
199
|
+
... vanity_name="examplecorp"
|
|
200
|
+
... )
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
id: str
|
|
204
|
+
name: str
|
|
205
|
+
localized_name: str
|
|
206
|
+
vanity_name: str | None = None
|
|
207
|
+
logo_url: str | None = None
|
|
208
|
+
follower_count: int | None = None
|
|
209
|
+
primary_type: OrganizationType | None = None
|
|
210
|
+
website_url: str | None = None
|
|
211
|
+
description: str | None = None
|
|
212
|
+
industry: str | None = None
|
|
213
|
+
raw_data: dict[str, Any] = Field(default_factory=dict)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class LinkedInPostContent(BaseModel):
|
|
217
|
+
"""Content attachment for a LinkedIn post.
|
|
218
|
+
|
|
219
|
+
Can contain media (image/video/document) or an article link.
|
|
220
|
+
|
|
221
|
+
Attributes:
|
|
222
|
+
media: Media content details
|
|
223
|
+
article: Article link details
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
media: "MediaContent | None" = None
|
|
227
|
+
article: "ArticleContent | None" = None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class MediaContent(BaseModel):
|
|
231
|
+
"""Media content for a LinkedIn post.
|
|
232
|
+
|
|
233
|
+
Attributes:
|
|
234
|
+
id: Media URN (e.g., urn:li:image:xxx, urn:li:video:xxx)
|
|
235
|
+
title: Optional title for the media
|
|
236
|
+
alt_text: Alternative text for accessibility
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
id: str
|
|
240
|
+
title: str | None = None
|
|
241
|
+
alt_text: str | None = None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class ArticleContent(BaseModel):
|
|
245
|
+
"""Article content for a LinkedIn post.
|
|
246
|
+
|
|
247
|
+
Attributes:
|
|
248
|
+
source: URL of the article
|
|
249
|
+
title: Article title
|
|
250
|
+
description: Article description
|
|
251
|
+
thumbnail: URN of the thumbnail image
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
source: str
|
|
255
|
+
title: str | None = None
|
|
256
|
+
description: str | None = None
|
|
257
|
+
thumbnail: str | None = None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class LinkedInPostDistribution(BaseModel):
|
|
261
|
+
"""Distribution settings for a LinkedIn post.
|
|
262
|
+
|
|
263
|
+
Attributes:
|
|
264
|
+
feed_distribution: Where to distribute the post
|
|
265
|
+
target_entities: Audience targeting criteria
|
|
266
|
+
third_party_distribution_channels: External distribution channels
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
feed_distribution: FeedDistribution = FeedDistribution.MAIN_FEED
|
|
270
|
+
target_entities: list[dict[str, Any]] = Field(default_factory=list)
|
|
271
|
+
third_party_distribution_channels: list[str] = Field(default_factory=list)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class CommentMention(BaseModel):
|
|
275
|
+
"""Mention in a comment.
|
|
276
|
+
|
|
277
|
+
Attributes:
|
|
278
|
+
start: Start position in the text
|
|
279
|
+
length: Length of the mention text
|
|
280
|
+
person_urn: URN of the mentioned person (if person mention)
|
|
281
|
+
organization_urn: URN of the mentioned organization (if org mention)
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
start: int
|
|
285
|
+
length: int
|
|
286
|
+
person_urn: str | None = None
|
|
287
|
+
organization_urn: str | None = None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class LinkedInCommentRequest(BaseModel):
|
|
291
|
+
"""Request model for creating or updating a LinkedIn comment.
|
|
292
|
+
|
|
293
|
+
Attributes:
|
|
294
|
+
content: Text content of the comment
|
|
295
|
+
parent_comment_id: URN of parent comment (for nested replies)
|
|
296
|
+
mentions: List of mentions to include
|
|
297
|
+
image_id: URN of image to attach to comment
|
|
298
|
+
|
|
299
|
+
Example:
|
|
300
|
+
>>> request = LinkedInCommentRequest(
|
|
301
|
+
... content="Great post! @[John Doe](urn:li:person:abc123)",
|
|
302
|
+
... mentions=[CommentMention(start=12, length=8, person_urn="urn:li:person:abc123")]
|
|
303
|
+
... )
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
content: str
|
|
307
|
+
parent_comment_id: str | None = None
|
|
308
|
+
mentions: list[CommentMention] = Field(default_factory=list)
|
|
309
|
+
image_id: str | None = None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# Type alias for sort options
|
|
313
|
+
type PostSortBy = Literal["LAST_MODIFIED", "CREATED"]
|
|
314
|
+
type ReactionSortBy = Literal["CHRONOLOGICAL", "REVERSE_CHRONOLOGICAL", "RELEVANCE"]
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class LinkedInPostRequest(BaseModel):
|
|
318
|
+
"""LinkedIn-specific post creation request.
|
|
319
|
+
|
|
320
|
+
Supports text posts, articles, media (images/videos/documents), and
|
|
321
|
+
rich formatting options like CTAs and visibility controls.
|
|
322
|
+
LinkedIn has a 3000 character limit for post content.
|
|
323
|
+
|
|
324
|
+
Attributes:
|
|
325
|
+
content: Post text/commentary (max 3000 characters)
|
|
326
|
+
media_urls: List of media URLs to upload
|
|
327
|
+
media_ids: List of pre-uploaded media URNs
|
|
328
|
+
link: Article/link URL to share
|
|
329
|
+
visibility: Post visibility (PUBLIC, CONNECTIONS, LOGGED_IN)
|
|
330
|
+
feed_distribution: Feed distribution (MAIN_FEED, NONE)
|
|
331
|
+
target_entities: Audience targeting criteria
|
|
332
|
+
call_to_action: CTA button label
|
|
333
|
+
landing_page: URL for CTA button
|
|
334
|
+
article_title: Title for link preview
|
|
335
|
+
article_description: Description for link preview
|
|
336
|
+
article_thumbnail: Thumbnail URN for article
|
|
337
|
+
media_title: Title for media attachment
|
|
338
|
+
media_alt_text: Alt text for media (accessibility)
|
|
339
|
+
disable_reshare: Prevent resharing
|
|
340
|
+
disable_comments: Disable comments
|
|
341
|
+
|
|
342
|
+
Example:
|
|
343
|
+
>>> # Simple post
|
|
344
|
+
>>> request = LinkedInPostRequest(
|
|
345
|
+
... content="Excited to share our latest update!"
|
|
346
|
+
... )
|
|
347
|
+
|
|
348
|
+
>>> # Article post with CTA
|
|
349
|
+
>>> request = LinkedInPostRequest(
|
|
350
|
+
... content="Check out our new blog post!",
|
|
351
|
+
... link="https://example.com/blog",
|
|
352
|
+
... article_title="Our Latest Update",
|
|
353
|
+
... article_description="Learn about new features",
|
|
354
|
+
... call_to_action=CallToActionLabel.LEARN_MORE,
|
|
355
|
+
... landing_page="https://example.com/learn-more"
|
|
356
|
+
... )
|
|
357
|
+
|
|
358
|
+
>>> # Post with media and visibility control
|
|
359
|
+
>>> request = LinkedInPostRequest(
|
|
360
|
+
... content="New product launch!",
|
|
361
|
+
... media_urls=["https://example.com/product.jpg"],
|
|
362
|
+
... visibility=PostVisibility.PUBLIC,
|
|
363
|
+
... media_alt_text="Product image"
|
|
364
|
+
... )
|
|
365
|
+
"""
|
|
366
|
+
|
|
367
|
+
content: str
|
|
368
|
+
media_urls: list[str] = Field(default_factory=list)
|
|
369
|
+
media_ids: list[str] = Field(default_factory=list)
|
|
370
|
+
link: str | None = None
|
|
371
|
+
visibility: PostVisibility = PostVisibility.PUBLIC
|
|
372
|
+
feed_distribution: FeedDistribution = FeedDistribution.MAIN_FEED
|
|
373
|
+
target_entities: list[dict[str, Any]] = Field(default_factory=list)
|
|
374
|
+
call_to_action: CallToActionLabel | None = None
|
|
375
|
+
landing_page: str | None = None
|
|
376
|
+
article_title: str | None = None
|
|
377
|
+
article_description: str | None = None
|
|
378
|
+
article_thumbnail: str | None = None
|
|
379
|
+
media_title: str | None = None
|
|
380
|
+
media_alt_text: str | None = None
|
|
381
|
+
disable_reshare: bool = False
|
|
382
|
+
disable_comments: bool = False
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class LinkedInPostUpdateRequest(BaseModel):
|
|
386
|
+
"""LinkedIn-specific post update request.
|
|
387
|
+
|
|
388
|
+
LinkedIn supports updating certain fields of published posts:
|
|
389
|
+
- commentary (post text)
|
|
390
|
+
- call-to-action label and landing page
|
|
391
|
+
- lifecycle state
|
|
392
|
+
- ad context (for sponsored content)
|
|
393
|
+
|
|
394
|
+
Attributes:
|
|
395
|
+
content: Updated post text/commentary
|
|
396
|
+
call_to_action: Updated CTA button label
|
|
397
|
+
landing_page: Updated URL for CTA button
|
|
398
|
+
lifecycle_state: Updated lifecycle state (e.g., PUBLISHED, DRAFT)
|
|
399
|
+
ad_context: Ad context updates for sponsored content
|
|
400
|
+
|
|
401
|
+
Example:
|
|
402
|
+
>>> request = LinkedInPostUpdateRequest(
|
|
403
|
+
... content="Updated post content!",
|
|
404
|
+
... call_to_action=CallToActionLabel.SIGN_UP,
|
|
405
|
+
... landing_page="https://example.com/signup"
|
|
406
|
+
... )
|
|
407
|
+
"""
|
|
408
|
+
|
|
409
|
+
content: str | None = None
|
|
410
|
+
call_to_action: CallToActionLabel | None = None
|
|
411
|
+
landing_page: str | None = None
|
|
412
|
+
lifecycle_state: str | None = None
|
|
413
|
+
ad_context: dict[str, Any] | None = None
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""TikTok platform integration."""
|
|
2
2
|
|
|
3
3
|
from marqetive.platforms.tiktok.client import TikTokClient
|
|
4
|
+
from marqetive.platforms.tiktok.models import PrivacyLevel, TikTokPostRequest
|
|
4
5
|
|
|
5
|
-
__all__ = ["TikTokClient"]
|
|
6
|
+
__all__ = ["TikTokClient", "TikTokPostRequest", "PrivacyLevel"]
|
|
@@ -10,10 +10,11 @@ Reference: https://developers.tiktok.com/doc/content-posting-api-reference-direc
|
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
12
|
import asyncio
|
|
13
|
+
import inspect
|
|
13
14
|
import logging
|
|
14
15
|
import math
|
|
15
16
|
import os
|
|
16
|
-
from collections.abc import Callable
|
|
17
|
+
from collections.abc import Awaitable, Callable
|
|
17
18
|
from dataclasses import dataclass
|
|
18
19
|
from enum import Enum
|
|
19
20
|
from typing import Any, Literal
|
|
@@ -24,10 +25,16 @@ from marqetive.core.exceptions import (
|
|
|
24
25
|
InvalidFileTypeError,
|
|
25
26
|
MediaUploadError,
|
|
26
27
|
)
|
|
28
|
+
from marqetive.core.models import ProgressEvent, ProgressStatus
|
|
27
29
|
from marqetive.platforms.tiktok.exceptions import TikTokErrorCode, map_tiktok_error
|
|
28
30
|
from marqetive.utils.file_handlers import download_file
|
|
29
31
|
from marqetive.utils.media import detect_mime_type, format_file_size
|
|
30
32
|
|
|
33
|
+
# Type aliases for progress callbacks
|
|
34
|
+
type SyncProgressCallback = Callable[[ProgressEvent], None]
|
|
35
|
+
type AsyncProgressCallback = Callable[[ProgressEvent], Awaitable[None]]
|
|
36
|
+
type ProgressCallback = SyncProgressCallback | AsyncProgressCallback
|
|
37
|
+
|
|
31
38
|
logger = logging.getLogger(__name__)
|
|
32
39
|
|
|
33
40
|
# TikTok API Base URLs
|
|
@@ -72,7 +79,19 @@ class PrivacyLevel(str, Enum):
|
|
|
72
79
|
|
|
73
80
|
@dataclass
|
|
74
81
|
class UploadProgress:
|
|
75
|
-
"""Progress information for a media upload.
|
|
82
|
+
"""Progress information for a media upload.
|
|
83
|
+
|
|
84
|
+
.. deprecated:: 0.2.0
|
|
85
|
+
Use :class:`marqetive.core.models.ProgressEvent` instead.
|
|
86
|
+
This class will be removed in a future version.
|
|
87
|
+
|
|
88
|
+
Attributes:
|
|
89
|
+
publish_id: TikTok publish ID (if available).
|
|
90
|
+
file_path: Path to file being uploaded.
|
|
91
|
+
bytes_uploaded: Number of bytes uploaded so far.
|
|
92
|
+
total_bytes: Total file size in bytes.
|
|
93
|
+
status: Current upload status.
|
|
94
|
+
"""
|
|
76
95
|
|
|
77
96
|
publish_id: str | None
|
|
78
97
|
file_path: str
|
|
@@ -141,7 +160,7 @@ class TikTokMediaManager:
|
|
|
141
160
|
access_token: str,
|
|
142
161
|
open_id: str,
|
|
143
162
|
*,
|
|
144
|
-
progress_callback:
|
|
163
|
+
progress_callback: ProgressCallback | None = None,
|
|
145
164
|
timeout: float = DEFAULT_REQUEST_TIMEOUT,
|
|
146
165
|
) -> None:
|
|
147
166
|
"""Initialize the TikTok media manager.
|
|
@@ -149,7 +168,8 @@ class TikTokMediaManager:
|
|
|
149
168
|
Args:
|
|
150
169
|
access_token: OAuth access token with video.publish scope.
|
|
151
170
|
open_id: User's open_id from OAuth flow.
|
|
152
|
-
progress_callback: Optional callback for
|
|
171
|
+
progress_callback: Optional callback for progress updates.
|
|
172
|
+
Accepts ProgressEvent and can be sync or async.
|
|
153
173
|
timeout: Request timeout in seconds.
|
|
154
174
|
"""
|
|
155
175
|
self.access_token = access_token
|
|
@@ -176,6 +196,44 @@ class TikTokMediaManager:
|
|
|
176
196
|
await self._client.aclose()
|
|
177
197
|
await self._upload_client.aclose()
|
|
178
198
|
|
|
199
|
+
async def _emit_progress(
|
|
200
|
+
self,
|
|
201
|
+
status: ProgressStatus,
|
|
202
|
+
progress: int,
|
|
203
|
+
total: int,
|
|
204
|
+
message: str | None = None,
|
|
205
|
+
*,
|
|
206
|
+
entity_id: str | None = None,
|
|
207
|
+
file_path: str | None = None,
|
|
208
|
+
bytes_uploaded: int | None = None,
|
|
209
|
+
total_bytes: int | None = None,
|
|
210
|
+
) -> None:
|
|
211
|
+
"""Emit a progress update if a callback is registered.
|
|
212
|
+
|
|
213
|
+
Supports both sync and async callbacks.
|
|
214
|
+
"""
|
|
215
|
+
if self.progress_callback is None:
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
event = ProgressEvent(
|
|
219
|
+
operation="upload_media",
|
|
220
|
+
platform="tiktok",
|
|
221
|
+
status=status,
|
|
222
|
+
progress=progress,
|
|
223
|
+
total=total,
|
|
224
|
+
message=message,
|
|
225
|
+
entity_id=entity_id,
|
|
226
|
+
file_path=file_path,
|
|
227
|
+
bytes_uploaded=bytes_uploaded,
|
|
228
|
+
total_bytes=total_bytes,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
result = self.progress_callback(event)
|
|
232
|
+
|
|
233
|
+
# If callback returned a coroutine, await it
|
|
234
|
+
if inspect.iscoroutine(result):
|
|
235
|
+
await result
|
|
236
|
+
|
|
179
237
|
async def query_creator_info(self) -> CreatorInfo:
|
|
180
238
|
"""Query creator info before posting.
|
|
181
239
|
|
|
@@ -368,16 +426,16 @@ class TikTokMediaManager:
|
|
|
368
426
|
chunk_size = self._normalize_chunk_size(chunk_size, file_size)
|
|
369
427
|
mime_type = detect_mime_type(file_path)
|
|
370
428
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
429
|
+
await self._emit_progress(
|
|
430
|
+
status=ProgressStatus.UPLOADING,
|
|
431
|
+
progress=0,
|
|
432
|
+
total=100,
|
|
433
|
+
message="Starting video upload",
|
|
434
|
+
entity_id=publish_id,
|
|
435
|
+
file_path=file_path,
|
|
436
|
+
bytes_uploaded=0,
|
|
437
|
+
total_bytes=file_size,
|
|
438
|
+
)
|
|
381
439
|
|
|
382
440
|
bytes_uploaded = 0
|
|
383
441
|
|
|
@@ -419,16 +477,16 @@ class TikTokMediaManager:
|
|
|
419
477
|
bytes_uploaded += len(chunk_data)
|
|
420
478
|
chunk_index += 1
|
|
421
479
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
480
|
+
await self._emit_progress(
|
|
481
|
+
status=ProgressStatus.UPLOADING,
|
|
482
|
+
progress=int((bytes_uploaded / file_size) * 100),
|
|
483
|
+
total=100,
|
|
484
|
+
message=f"Uploading chunk {chunk_index}",
|
|
485
|
+
entity_id=publish_id,
|
|
486
|
+
file_path=file_path,
|
|
487
|
+
bytes_uploaded=bytes_uploaded,
|
|
488
|
+
total_bytes=file_size,
|
|
489
|
+
)
|
|
432
490
|
|
|
433
491
|
logger.info(
|
|
434
492
|
f"TikTok video upload complete: {bytes_uploaded} bytes in {chunk_index} chunks"
|
|
@@ -490,16 +548,17 @@ class TikTokMediaManager:
|
|
|
490
548
|
Raises:
|
|
491
549
|
MediaUploadError: If publishing fails or times out.
|
|
492
550
|
"""
|
|
493
|
-
if
|
|
551
|
+
if file_path:
|
|
494
552
|
file_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
|
|
495
|
-
self.
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
553
|
+
await self._emit_progress(
|
|
554
|
+
status=ProgressStatus.PROCESSING,
|
|
555
|
+
progress=0,
|
|
556
|
+
total=100,
|
|
557
|
+
message="Processing video on TikTok servers",
|
|
558
|
+
entity_id=publish_id,
|
|
559
|
+
file_path=file_path,
|
|
560
|
+
bytes_uploaded=file_size,
|
|
561
|
+
total_bytes=file_size,
|
|
503
562
|
)
|
|
504
563
|
|
|
505
564
|
elapsed = 0.0
|
|
@@ -508,31 +567,33 @@ class TikTokMediaManager:
|
|
|
508
567
|
|
|
509
568
|
if result.status == PublishStatus.PUBLISH_COMPLETE:
|
|
510
569
|
logger.info(f"TikTok video published: video_id={result.video_id}")
|
|
511
|
-
if
|
|
570
|
+
if file_path:
|
|
512
571
|
file_size = (
|
|
513
572
|
os.path.getsize(file_path) if os.path.exists(file_path) else 0
|
|
514
573
|
)
|
|
515
|
-
self.
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
574
|
+
await self._emit_progress(
|
|
575
|
+
status=ProgressStatus.COMPLETED,
|
|
576
|
+
progress=100,
|
|
577
|
+
total=100,
|
|
578
|
+
message="Video published successfully",
|
|
579
|
+
entity_id=publish_id,
|
|
580
|
+
file_path=file_path,
|
|
581
|
+
bytes_uploaded=file_size,
|
|
582
|
+
total_bytes=file_size,
|
|
523
583
|
)
|
|
524
584
|
return result
|
|
525
585
|
|
|
526
586
|
if result.status == PublishStatus.FAILED:
|
|
527
|
-
if
|
|
528
|
-
self.
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
587
|
+
if file_path:
|
|
588
|
+
await self._emit_progress(
|
|
589
|
+
status=ProgressStatus.FAILED,
|
|
590
|
+
progress=0,
|
|
591
|
+
total=100,
|
|
592
|
+
message="Video publishing failed",
|
|
593
|
+
entity_id=publish_id,
|
|
594
|
+
file_path=file_path,
|
|
595
|
+
bytes_uploaded=0,
|
|
596
|
+
total_bytes=0,
|
|
536
597
|
)
|
|
537
598
|
raise MediaUploadError(
|
|
538
599
|
f"TikTok video publishing failed: publish_id={publish_id}",
|