marqetive-lib 0.1.6__py3-none-any.whl → 0.1.8__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 CHANGED
@@ -62,6 +62,8 @@ from marqetive.core.models import (
62
62
  PostCreateRequest,
63
63
  PostStatus,
64
64
  PostUpdateRequest,
65
+ ProgressEvent,
66
+ ProgressStatus,
65
67
  )
66
68
 
67
69
  # Factory
@@ -85,17 +87,21 @@ __all__ = [
85
87
  "BackoffConfig",
86
88
  "STANDARD_BACKOFF",
87
89
  "retry_async",
88
- # Models
89
- "AuthCredentials",
90
+ # Enums
90
91
  "AccountStatus",
91
- "Post",
92
+ "CommentStatus",
93
+ "MediaType",
92
94
  "PostStatus",
93
- "PostCreateRequest",
94
- "PostUpdateRequest",
95
+ "ProgressStatus",
96
+ # Core Models
97
+ "AuthCredentials",
95
98
  "Comment",
96
- "CommentStatus",
97
99
  "MediaAttachment",
98
- "MediaType",
100
+ "Post",
101
+ "ProgressEvent",
102
+ # Base Request Models
103
+ "PostCreateRequest",
104
+ "PostUpdateRequest",
99
105
  # Exceptions
100
106
  "PlatformError",
101
107
  "PlatformAuthError",
@@ -31,17 +31,19 @@ __all__ = [
31
31
  # Base class
32
32
  "SocialMediaPlatform",
33
33
  "ProgressCallback",
34
- # Models
34
+ # Enums
35
35
  "AccountStatus",
36
+ "CommentStatus",
37
+ "MediaType",
38
+ "PostStatus",
39
+ # Core Models
36
40
  "AuthCredentials",
37
41
  "Comment",
38
- "CommentStatus",
39
42
  "MediaAttachment",
40
- "MediaType",
41
43
  "PlatformResponse",
42
44
  "Post",
45
+ # Base Request Models
43
46
  "PostCreateRequest",
44
- "PostStatus",
45
47
  "PostUpdateRequest",
46
48
  # Exceptions
47
49
  "InvalidFileTypeError",
marqetive/core/base.py CHANGED
@@ -5,8 +5,9 @@ for implementing platform-specific clients (Instagram, Twitter, LinkedIn, etc.).
5
5
  All concrete implementations must implement the abstract methods defined here.
6
6
  """
7
7
 
8
+ import inspect
8
9
  from abc import ABC, abstractmethod
9
- from collections.abc import Callable
10
+ from collections.abc import Awaitable, Callable
10
11
  from datetime import datetime
11
12
  from traceback import TracebackException
12
13
  from typing import Any
@@ -24,11 +25,15 @@ from marqetive.core.models import (
24
25
  Post,
25
26
  PostCreateRequest,
26
27
  PostUpdateRequest,
28
+ ProgressEvent,
29
+ ProgressStatus,
27
30
  )
28
31
 
29
- # Type alias for progress callback
30
- # (operation: str, progress: int, total: int, message: str | None) -> None
31
- type ProgressCallback = Callable[[str, int, int, str | None], None]
32
+ # Type aliases for progress callbacks
33
+ # Supports both sync and async callbacks using ProgressEvent
34
+ type SyncProgressCallback = Callable[[ProgressEvent], None]
35
+ type AsyncProgressCallback = Callable[[ProgressEvent], Awaitable[None]]
36
+ type ProgressCallback = SyncProgressCallback | AsyncProgressCallback
32
37
 
33
38
 
34
39
  class SocialMediaPlatform(ABC):
@@ -162,28 +167,66 @@ class SocialMediaPlatform(ABC):
162
167
  self._rate_limit_remaining = remaining
163
168
  self._rate_limit_reset = reset_time
164
169
 
165
- def _emit_progress(
170
+ async def _emit_progress(
166
171
  self,
167
172
  operation: str,
173
+ status: ProgressStatus,
168
174
  progress: int,
169
175
  total: int,
170
176
  message: str | None = None,
177
+ *,
178
+ entity_id: str | None = None,
179
+ file_path: str | None = None,
180
+ bytes_uploaded: int | None = None,
181
+ total_bytes: int | None = None,
171
182
  ) -> None:
172
183
  """Emit a progress update if a callback is registered.
173
184
 
174
- This method is safe to call even if no callback is registered.
185
+ This method supports both synchronous and asynchronous callbacks.
186
+ It is safe to call even if no callback is registered.
175
187
 
176
188
  Args:
177
- operation: Name of the operation (e.g., "upload_media", "create_post")
178
- progress: Current progress value
179
- total: Total value for completion
180
- message: Optional human-readable message
189
+ operation: Name of the operation (e.g., "upload_media", "create_post", "create_thread")
190
+ status: Current status of the operation
191
+ progress: Current progress value (0-100 for percentage, or bytes)
192
+ total: Total value for completion (100 for percentage, or total bytes)
193
+ message: Optional human-readable status message
194
+ entity_id: Optional platform-specific ID (media_id, post_id, etc.)
195
+ file_path: Optional file path for upload operations
196
+ bytes_uploaded: Optional bytes uploaded so far
197
+ total_bytes: Optional total bytes to upload
181
198
 
182
199
  Example:
183
- >>> self._emit_progress("upload_media", 1, 3, "Uploading image 1 of 3")
200
+ >>> await self._emit_progress(
201
+ ... operation="upload_media",
202
+ ... status=ProgressStatus.UPLOADING,
203
+ ... progress=50,
204
+ ... total=100,
205
+ ... message="Uploading image 1 of 3",
206
+ ... entity_id="media_123",
207
+ ... )
184
208
  """
185
- if self._progress_callback is not None:
186
- self._progress_callback(operation, progress, total, message)
209
+ if self._progress_callback is None:
210
+ return
211
+
212
+ event = ProgressEvent(
213
+ operation=operation,
214
+ platform=self.platform_name,
215
+ status=status,
216
+ progress=progress,
217
+ total=total,
218
+ message=message,
219
+ entity_id=entity_id,
220
+ file_path=file_path,
221
+ bytes_uploaded=bytes_uploaded,
222
+ total_bytes=total_bytes,
223
+ )
224
+
225
+ result = self._progress_callback(event)
226
+
227
+ # If callback returned a coroutine, await it
228
+ if inspect.iscoroutine(result):
229
+ await result
187
230
 
188
231
  # ==================== Abstract Authentication Methods ====================
189
232
 
@@ -307,6 +350,42 @@ class SocialMediaPlatform(ABC):
307
350
  """
308
351
  pass
309
352
 
353
+ async def create_thread(
354
+ self,
355
+ posts: list[PostCreateRequest],
356
+ ) -> list[Post]:
357
+ """Create a thread of connected posts.
358
+
359
+ Not all platforms support threads. The default implementation raises
360
+ NotImplementedError. Platforms that support threads (like Twitter)
361
+ should override this method.
362
+
363
+ Args:
364
+ posts: List of post requests to create as a thread.
365
+ Each post can have its own content, media, etc.
366
+ First post is the head of the thread.
367
+
368
+ Returns:
369
+ List of Post objects for each post in the thread.
370
+
371
+ Raises:
372
+ NotImplementedError: If platform doesn't support threads.
373
+ ValidationError: If posts list is empty.
374
+ PlatformAuthError: If not authenticated.
375
+
376
+ Example:
377
+ >>> # Twitter thread example
378
+ >>> posts = [
379
+ ... TwitterPostRequest(content="Thread start! 1/3"),
380
+ ... TwitterPostRequest(content="Middle 2/3", media_urls=[...]),
381
+ ... TwitterPostRequest(content="End 3/3"),
382
+ ... ]
383
+ >>> thread = await client.create_thread(posts)
384
+ """
385
+ raise NotImplementedError(
386
+ f"{self.platform_name} does not support thread creation"
387
+ )
388
+
310
389
  # ==================== Abstract Comment Methods ====================
311
390
 
312
391
  @abstractmethod
marqetive/core/client.py CHANGED
@@ -5,6 +5,11 @@ from typing import Any
5
5
  import httpx
6
6
  from pydantic import BaseModel
7
7
 
8
+ # Default connection pool limits for optimal performance
9
+ DEFAULT_MAX_CONNECTIONS = 100
10
+ DEFAULT_MAX_KEEPALIVE_CONNECTIONS = 20
11
+ DEFAULT_KEEPALIVE_EXPIRY = 30.0
12
+
8
13
 
9
14
  class APIResponse(BaseModel):
10
15
  """Response model for API calls."""
@@ -24,6 +29,8 @@ class APIClient:
24
29
  base_url: The base URL for API requests
25
30
  timeout: Request timeout in seconds (default: 30)
26
31
  headers: Optional default headers for all requests
32
+ max_connections: Maximum total connections in pool (default: 100)
33
+ max_keepalive_connections: Maximum persistent connections (default: 20)
27
34
 
28
35
  Example:
29
36
  >>> client = APIClient(base_url="https://api.example.com")
@@ -36,12 +43,19 @@ class APIClient:
36
43
  base_url: str,
37
44
  timeout: float = 30.0,
38
45
  headers: dict[str, str] | None = None,
46
+ max_connections: int = DEFAULT_MAX_CONNECTIONS,
47
+ max_keepalive_connections: int = DEFAULT_MAX_KEEPALIVE_CONNECTIONS,
39
48
  ) -> None:
40
49
  """Initialize the API client."""
41
50
  self.base_url = base_url.rstrip("/")
42
51
  self.timeout = timeout
43
52
  self.default_headers = headers or {}
44
53
  self._client: httpx.AsyncClient | None = None
54
+ self._limits = httpx.Limits(
55
+ max_connections=max_connections,
56
+ max_keepalive_connections=max_keepalive_connections,
57
+ keepalive_expiry=DEFAULT_KEEPALIVE_EXPIRY,
58
+ )
45
59
 
46
60
  async def __aenter__(self) -> "APIClient":
47
61
  """Async context manager entry."""
@@ -49,6 +63,7 @@ class APIClient:
49
63
  base_url=self.base_url,
50
64
  timeout=self.timeout,
51
65
  headers=self.default_headers,
66
+ limits=self._limits,
52
67
  )
53
68
  return self
54
69
 
marqetive/core/models.py CHANGED
@@ -6,10 +6,10 @@ and type safety.
6
6
  """
7
7
 
8
8
  from datetime import UTC, datetime, timedelta
9
- from enum import Enum
9
+ from enum import Enum, StrEnum
10
10
  from typing import Any
11
11
 
12
- from pydantic import BaseModel, Field, HttpUrl
12
+ from pydantic import BaseModel, ConfigDict, Field, HttpUrl
13
13
 
14
14
 
15
15
  class MediaType(str, Enum):
@@ -19,6 +19,8 @@ class MediaType(str, Enum):
19
19
  VIDEO = "video"
20
20
  CAROUSEL = "carousel"
21
21
  DOCUMENT = "document"
22
+ REEL = "reel"
23
+ STORY = "story"
22
24
 
23
25
 
24
26
  class PostStatus(str, Enum):
@@ -55,6 +57,29 @@ class AccountStatus(str, Enum):
55
57
  ERROR = "error"
56
58
 
57
59
 
60
+ class ProgressStatus(StrEnum):
61
+ """Standard progress status for operations across all platforms.
62
+
63
+ Used with ProgressEvent to provide consistent progress tracking
64
+ for long-running operations like media uploads and post creation.
65
+
66
+ Attributes:
67
+ INITIALIZING: Operation is starting, preparing resources.
68
+ UPLOADING: Actively transferring data (e.g., uploading media).
69
+ PROCESSING: Server-side processing (e.g., video transcoding).
70
+ FINALIZING: Completing the operation (e.g., publishing post).
71
+ COMPLETED: Operation finished successfully.
72
+ FAILED: Operation failed with an error.
73
+ """
74
+
75
+ INITIALIZING = "initializing"
76
+ UPLOADING = "uploading"
77
+ PROCESSING = "processing"
78
+ FINALIZING = "finalizing"
79
+ COMPLETED = "completed"
80
+ FAILED = "failed"
81
+
82
+
58
83
  class MediaAttachment(BaseModel):
59
84
  """Represents a media attachment (image, video, etc.).
60
85
 
@@ -294,8 +319,82 @@ class PlatformResponse(BaseModel):
294
319
  rate_limit_reset: datetime | None = None
295
320
 
296
321
 
322
+ class ProgressEvent(BaseModel):
323
+ """Unified progress event for tracking long-running operations.
324
+
325
+ Provides a consistent interface for progress callbacks across all platforms.
326
+ Supports both synchronous and asynchronous callbacks.
327
+
328
+ Attributes:
329
+ operation: Name of the operation (e.g., "upload_media", "create_post", "create_thread").
330
+ platform: Platform name (e.g., "twitter", "linkedin", "instagram", "tiktok").
331
+ status: Current status of the operation.
332
+ progress: Current progress value (0-100 for percentage, or bytes uploaded).
333
+ total: Total value for completion (100 for percentage, or total bytes).
334
+ message: Optional human-readable status message.
335
+ entity_id: Optional platform-specific ID (media_id, post_id, container_id).
336
+ file_path: Optional file path for upload operations.
337
+ bytes_uploaded: Optional bytes uploaded so far.
338
+ total_bytes: Optional total bytes to upload.
339
+
340
+ Example:
341
+ >>> # Progress callback for media upload
342
+ >>> def on_progress(event: ProgressEvent) -> None:
343
+ ... print(f"{event.operation}: {event.percentage:.1f}% - {event.message}")
344
+ ...
345
+ >>> # Async progress callback
346
+ >>> async def on_progress_async(event: ProgressEvent) -> None:
347
+ ... await log_to_database(event)
348
+
349
+ >>> event = ProgressEvent(
350
+ ... operation="upload_media",
351
+ ... platform="twitter",
352
+ ... status=ProgressStatus.UPLOADING,
353
+ ... progress=50,
354
+ ... total=100,
355
+ ... message="Uploading image 1 of 2",
356
+ ... bytes_uploaded=524288,
357
+ ... total_bytes=1048576,
358
+ ... )
359
+ >>> print(event.percentage) # 50.0
360
+ """
361
+
362
+ operation: str
363
+ platform: str
364
+ status: ProgressStatus
365
+ progress: int
366
+ total: int
367
+ message: str | None = None
368
+
369
+ # Optional detailed info
370
+ entity_id: str | None = None
371
+ file_path: str | None = None
372
+ bytes_uploaded: int | None = None
373
+ total_bytes: int | None = None
374
+
375
+ @property
376
+ def percentage(self) -> float:
377
+ """Calculate progress as a percentage.
378
+
379
+ Returns:
380
+ Progress percentage (0.0 to 100.0).
381
+ """
382
+ if self.total == 0:
383
+ return 0.0
384
+ return (self.progress / self.total) * 100
385
+
386
+ model_config = ConfigDict(frozen=True)
387
+
388
+
297
389
  class PostCreateRequest(BaseModel):
298
- """Request model for creating a new post.
390
+ """Base request model for creating a new post.
391
+
392
+ This is a minimal base model with universal fields that work across all platforms.
393
+ For platform-specific features, use the dedicated request models:
394
+ - TwitterPostRequest
395
+ - LinkedInPostRequest
396
+ - InstagramPostRequest
397
+ - TikTokPostRequest
299
398
 
300
399
  Attributes:
301
400
  content: Text content of the post
@@ -305,7 +404,7 @@ class PostCreateRequest(BaseModel):
305
404
  link: URL to include in the post
306
405
  tags: List of hashtags or user tags
307
406
  location: Location/place tag for the post
308
- additional_data: Platform-specific data
407
+ additional_data: Platform-specific data for backward compatibility
309
408
 
310
409
  Example:
311
410
  >>> request = PostCreateRequest(
@@ -326,10 +425,15 @@ class PostCreateRequest(BaseModel):
326
425
 
327
426
 
328
427
  class PostUpdateRequest(BaseModel):
329
- """Request model for updating an existing post.
428
+ """Base request model for updating an existing post.
429
+
430
+ Note: Not all platforms support editing posts:
431
+ - Twitter: No editing support (for most users)
432
+ - Instagram: No editing support
433
+ - TikTok: No editing support
434
+ - LinkedIn: Supports updating content, CTA, and landing page
330
435
 
331
- Note: Not all platforms support editing posts. Fields that can be
332
- updated vary by platform.
436
+ For LinkedIn-specific update features, use LinkedInPostUpdateRequest.
333
437
 
334
438
  Attributes:
335
439
  content: Updated text content
@@ -1,5 +1,6 @@
1
1
  """Instagram platform integration."""
2
2
 
3
3
  from marqetive.platforms.instagram.client import InstagramClient
4
+ from marqetive.platforms.instagram.models import InstagramPostRequest
4
5
 
5
- __all__ = ["InstagramClient"]
6
+ __all__ = ["InstagramClient", "InstagramPostRequest"]
@@ -35,6 +35,7 @@ from marqetive.platforms.instagram.media import (
35
35
  InstagramMediaManager,
36
36
  MediaItem,
37
37
  )
38
+ from marqetive.utils.media import validate_media_url
38
39
 
39
40
 
40
41
  class InstagramClient(SocialMediaPlatform):
@@ -245,7 +246,11 @@ class InstagramClient(SocialMediaPlatform):
245
246
  }
246
247
 
247
248
  if request.media_urls:
248
- container_params["image_url"] = request.media_urls[0]
249
+ # Validate URL to prevent SSRF attacks
250
+ validated_url = validate_media_url(
251
+ request.media_urls[0], platform=self.platform_name
252
+ )
253
+ container_params["image_url"] = validated_url
249
254
 
250
255
  if request.content:
251
256
  container_params["caption"] = request.content
@@ -533,14 +538,17 @@ class InstagramClient(SocialMediaPlatform):
533
538
  if not self.api_client:
534
539
  raise RuntimeError("Client must be used as async context manager")
535
540
 
541
+ # Validate URL to prevent SSRF attacks
542
+ validated_url = validate_media_url(media_url, platform=self.platform_name)
543
+
536
544
  params: dict[str, Any] = {
537
545
  "access_token": self.credentials.access_token,
538
546
  }
539
547
 
540
548
  if media_type.lower() == "image":
541
- params["image_url"] = media_url
549
+ params["image_url"] = validated_url
542
550
  elif media_type.lower() == "video":
543
- params["video_url"] = media_url
551
+ params["video_url"] = validated_url
544
552
  params["media_type"] = "VIDEO"
545
553
  else:
546
554
  raise ValidationError(
@@ -612,13 +620,15 @@ class InstagramClient(SocialMediaPlatform):
612
620
  if not self._media_manager:
613
621
  raise RuntimeError("Client must be used as async context manager")
614
622
 
615
- # Convert to MediaItem objects
623
+ # Convert to MediaItem objects with URL validation
616
624
  media_items = []
617
625
  for idx, url in enumerate(media_urls):
626
+ # Validate each URL to prevent SSRF attacks
627
+ validated_url = validate_media_url(url, platform=self.platform_name)
618
628
  alt_text = None
619
629
  if alt_texts and idx < len(alt_texts):
620
630
  alt_text = alt_texts[idx]
621
- media_items.append(MediaItem(url=url, type="image", alt_text=alt_text))
631
+ media_items.append(MediaItem(url=validated_url, type="image", alt_text=alt_text))
622
632
 
623
633
  # Create containers
624
634
  container_ids = await self._media_manager.create_feed_containers(
@@ -667,11 +677,19 @@ class InstagramClient(SocialMediaPlatform):
667
677
  if not self._media_manager:
668
678
  raise RuntimeError("Client must be used as async context manager")
669
679
 
680
+ # Validate URLs to prevent SSRF attacks
681
+ validated_video_url = validate_media_url(video_url, platform=self.platform_name)
682
+ validated_cover_url = None
683
+ if cover_url:
684
+ validated_cover_url = validate_media_url(
685
+ cover_url, platform=self.platform_name
686
+ )
687
+
670
688
  # Create reel container
671
689
  container_id = await self._media_manager.create_reel_container(
672
- video_url,
690
+ validated_video_url,
673
691
  caption=caption,
674
- cover_url=cover_url,
692
+ cover_url=validated_cover_url,
675
693
  share_to_feed=share_to_feed,
676
694
  wait_for_processing=True,
677
695
  )
@@ -710,9 +728,12 @@ class InstagramClient(SocialMediaPlatform):
710
728
  if not self._media_manager:
711
729
  raise RuntimeError("Client must be used as async context manager")
712
730
 
731
+ # Validate URL to prevent SSRF attacks
732
+ validated_url = validate_media_url(media_url, platform=self.platform_name)
733
+
713
734
  # Create story container
714
735
  container_id = await self._media_manager.create_story_container(
715
- media_url,
736
+ validated_url,
716
737
  media_type,
717
738
  wait_for_processing=(media_type == "video"),
718
739
  )
@@ -11,8 +11,9 @@ This module provides comprehensive media management for:
11
11
  """
12
12
 
13
13
  import asyncio
14
+ import inspect
14
15
  import logging
15
- from collections.abc import Callable
16
+ from collections.abc import Awaitable, Callable
16
17
  from dataclasses import dataclass
17
18
  from enum import Enum
18
19
  from typing import Any, Literal
@@ -23,8 +24,14 @@ from marqetive.core.exceptions import (
23
24
  MediaUploadError,
24
25
  ValidationError,
25
26
  )
27
+ from marqetive.core.models import ProgressEvent, ProgressStatus
26
28
  from marqetive.utils.retry import STANDARD_BACKOFF, retry_async
27
29
 
30
+ # Type aliases for progress callbacks
31
+ type SyncProgressCallback = Callable[[ProgressEvent], None]
32
+ type AsyncProgressCallback = Callable[[ProgressEvent], Awaitable[None]]
33
+ type ProgressCallback = SyncProgressCallback | AsyncProgressCallback
34
+
28
35
  logger = logging.getLogger(__name__)
29
36
 
30
37
  # Instagram API limits
@@ -136,7 +143,7 @@ class InstagramMediaManager:
136
143
  *,
137
144
  api_version: str = "v21.0",
138
145
  timeout: float = 30.0,
139
- progress_callback: Callable[[str, str, int], None] | None = None,
146
+ progress_callback: ProgressCallback | None = None,
140
147
  ) -> None:
141
148
  """Initialize Instagram media manager.
142
149
 
@@ -145,7 +152,8 @@ class InstagramMediaManager:
145
152
  access_token: Instagram/Facebook access token.
146
153
  api_version: Instagram Graph API version.
147
154
  timeout: Request timeout in seconds.
148
- progress_callback: Optional callback(container_id, status, progress_pct).
155
+ progress_callback: Optional callback for progress updates.
156
+ Receives ProgressEvent objects with upload status and metrics.
149
157
  """
150
158
  self.ig_user_id = ig_user_id
151
159
  self.access_token = access_token
@@ -167,6 +175,44 @@ class InstagramMediaManager:
167
175
  """Exit async context and cleanup."""
168
176
  await self.client.aclose()
169
177
 
178
+ async def _emit_progress(
179
+ self,
180
+ status: ProgressStatus,
181
+ progress: int,
182
+ total: int,
183
+ message: str | None = None,
184
+ *,
185
+ entity_id: str | None = None,
186
+ file_path: str | None = None,
187
+ bytes_uploaded: int | None = None,
188
+ total_bytes: int | None = None,
189
+ ) -> None:
190
+ """Emit a progress update if a callback is registered.
191
+
192
+ Supports both sync and async callbacks.
193
+ """
194
+ if self.progress_callback is None:
195
+ return
196
+
197
+ event = ProgressEvent(
198
+ operation="upload_media",
199
+ platform="instagram",
200
+ status=status,
201
+ progress=progress,
202
+ total=total,
203
+ message=message,
204
+ entity_id=entity_id,
205
+ file_path=file_path,
206
+ bytes_uploaded=bytes_uploaded,
207
+ total_bytes=total_bytes,
208
+ )
209
+
210
+ result = self.progress_callback(event)
211
+
212
+ # If callback returned a coroutine, await it
213
+ if inspect.iscoroutine(result):
214
+ await result
215
+
170
216
  async def create_feed_containers(
171
217
  self,
172
218
  media_items: list[MediaItem],
@@ -260,9 +306,14 @@ class InstagramMediaManager:
260
306
  container_ids.append(container_id)
261
307
 
262
308
  # Notify progress
263
- if self.progress_callback:
264
- progress = int(((idx + 1) / len(media_items)) * 100)
265
- self.progress_callback(container_id, "created", progress)
309
+ progress = int(((idx + 1) / len(media_items)) * 100)
310
+ await self._emit_progress(
311
+ status=ProgressStatus.COMPLETED,
312
+ progress=progress,
313
+ total=100,
314
+ message=f"Created container {idx + 1}/{len(media_items)}",
315
+ entity_id=container_id,
316
+ )
266
317
 
267
318
  # If carousel, create parent container
268
319
  if is_carousel:
@@ -355,8 +406,13 @@ class InstagramMediaManager:
355
406
  media_type="reel",
356
407
  )
357
408
 
358
- if self.progress_callback:
359
- self.progress_callback(container_id, "ready", 100)
409
+ await self._emit_progress(
410
+ status=ProgressStatus.COMPLETED,
411
+ progress=100,
412
+ total=100,
413
+ message="Reel container ready for publishing",
414
+ entity_id=container_id,
415
+ )
360
416
 
361
417
  return container_id
362
418
 
@@ -426,8 +482,13 @@ class InstagramMediaManager:
426
482
  media_type="story",
427
483
  )
428
484
 
429
- if self.progress_callback:
430
- self.progress_callback(container_id, "ready", 100)
485
+ await self._emit_progress(
486
+ status=ProgressStatus.COMPLETED,
487
+ progress=100,
488
+ total=100,
489
+ message="Story container ready for publishing",
490
+ entity_id=container_id,
491
+ )
431
492
 
432
493
  return container_id
433
494
 
@@ -549,9 +610,14 @@ class InstagramMediaManager:
549
610
  )
550
611
 
551
612
  # Notify progress
552
- if self.progress_callback:
553
- progress = min(int((elapsed / timeout) * 90), 90) # Cap at 90%
554
- self.progress_callback(container_id, "processing", progress)
613
+ progress = min(int((elapsed / timeout) * 90), 90) # Cap at 90%
614
+ await self._emit_progress(
615
+ status=ProgressStatus.PROCESSING,
616
+ progress=progress,
617
+ total=100,
618
+ message=f"Processing {media_type} container",
619
+ entity_id=container_id,
620
+ )
555
621
 
556
622
  await asyncio.sleep(check_interval)
557
623
  elapsed += check_interval