creatorloop 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,196 @@
1
+ """Publishing queue: you approve a post and a time, the server publishes it then.
2
+
3
+ Safety rules:
4
+ - an item is created only by queue_post(confirm=True), i.e. after the user approved this exact preview;
5
+ - at approval the server stores a fingerprint of what will be published (caption before '---', media files,
6
+ reel.json); if anything changed by publish time, the item is BLOCKED instead of published;
7
+ - if the server was not running at the slot (laptop asleep, Claude Desktop closed), the post is published
8
+ at most LATE_WINDOW late, otherwise it is marked MISSED and nothing is posted.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import hashlib
15
+ import logging
16
+ import os
17
+ import re
18
+ import shutil
19
+ import tempfile
20
+ from collections.abc import Callable
21
+ from datetime import UTC, datetime, timedelta
22
+ from pathlib import Path
23
+ from typing import Any
24
+ from zoneinfo import ZoneInfo
25
+
26
+ from creatorloop.publish import (
27
+ Publisher,
28
+ PublishError,
29
+ check_caption,
30
+ check_video,
31
+ list_images,
32
+ list_videos,
33
+ read_caption,
34
+ read_reel_settings,
35
+ resolve_folder,
36
+ to_jpegs,
37
+ )
38
+
39
+ log = logging.getLogger(__name__)
40
+
41
+ DEFAULT_SLOTS = "Tue,Thu,Sun 19:00" # override with QUEUE_SLOTS in .env, local time (TIMEZONE)
42
+ LATE_WINDOW = timedelta(hours=3)
43
+ MIN_LEAD = timedelta(minutes=5)
44
+ MAX_AHEAD = timedelta(days=30)
45
+ TICK_S = 60
46
+ DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
47
+
48
+ # One publish at a time: shared by the queue runner and prepare_post
49
+ PUBLISH_LOCK = asyncio.Lock()
50
+
51
+
52
+ class QueueError(RuntimeError):
53
+ pass
54
+
55
+
56
+ # ---------------------------------------------------------------- what exactly gets published
57
+ def fingerprint(folder: Path) -> str:
58
+ h = hashlib.sha256(read_caption(folder).encode())
59
+ videos = list_videos(folder)
60
+ files = [videos[0], *[p for p in [folder / "reel.json"] if p.exists()]] if videos else list_images(folder)
61
+ for f in files:
62
+ h.update(f.name.encode())
63
+ h.update(f.read_bytes())
64
+ return h.hexdigest()[:16]
65
+
66
+
67
+ def validate(folder: Path) -> dict[str, Any]:
68
+ """Same local checks as prepare_post, without any API call."""
69
+ caption = read_caption(folder)
70
+ stats = check_caption(caption)
71
+ videos = list_videos(folder)
72
+ if len(videos) > 1:
73
+ raise PublishError(f"{len(videos)} videos in {folder.name}; a Reel takes exactly one .mp4/.mov")
74
+ if videos:
75
+ info = {"type": "REELS", "video": check_video(videos[0]), "reel": read_reel_settings(folder)}
76
+ else:
77
+ images = list_images(folder)
78
+ work = Path(tempfile.mkdtemp(prefix="igq-"))
79
+ try:
80
+ to_jpegs(images, work)
81
+ finally:
82
+ shutil.rmtree(work, ignore_errors=True)
83
+ info = {"type": "CAROUSEL" if len(images) > 1 else "IMAGE", "slides": [p.name for p in images]}
84
+ first = caption.splitlines()[0] if caption else ""
85
+ return {**info, "caption_hook": first[:150], "caption_stats": stats}
86
+
87
+
88
+ # ---------------------------------------------------------------- time slots
89
+ def parse_slots(spec: str | None = None) -> tuple[set[int], int, int]:
90
+ spec = (spec or os.getenv("QUEUE_SLOTS") or DEFAULT_SLOTS).strip()
91
+ m = re.fullmatch(r"([A-Za-z,\s]+)\s+(\d{1,2}):(\d{2})", spec)
92
+ if not m:
93
+ raise QueueError(f"QUEUE_SLOTS must look like '{DEFAULT_SLOTS}', got {spec!r}")
94
+ days = {DAYS.index(d.strip().lower()[:3]) for d in m.group(1).split(",") if d.strip()}
95
+ return days, int(m.group(2)), int(m.group(3))
96
+
97
+
98
+ def next_slots(tz: ZoneInfo, now: datetime, taken: set[datetime], n: int = 3, spec: str | None = None) -> list[datetime]:
99
+ days, hh, mm = parse_slots(spec)
100
+ out: list[datetime] = []
101
+ local = now.astimezone(tz)
102
+ for i in range(0, 60):
103
+ d = (local + timedelta(days=i)).date()
104
+ if d.weekday() not in days:
105
+ continue
106
+ slot = datetime(d.year, d.month, d.day, hh, mm, tzinfo=tz).astimezone(UTC)
107
+ if slot >= now + MIN_LEAD and slot not in taken:
108
+ out.append(slot)
109
+ if len(out) == n:
110
+ break
111
+ return out
112
+
113
+
114
+ def parse_when(when: str | None, tz: ZoneInfo, now: datetime, taken: set[datetime]) -> datetime:
115
+ """None/'next' = next free default slot; otherwise 'YYYY-MM-DD HH:MM' in local time (or ISO with offset)."""
116
+ if not when or when.strip().lower() == "next":
117
+ slots = next_slots(tz, now, taken, n=1)
118
+ if not slots:
119
+ raise QueueError("No free slot in the next 60 days")
120
+ return slots[0]
121
+ try:
122
+ dt = datetime.fromisoformat(when.strip().replace(" ", "T", 1))
123
+ except ValueError as exc:
124
+ raise QueueError("when must be 'next' or 'YYYY-MM-DD HH:MM' (local time)") from exc
125
+ dt = (dt if dt.tzinfo else dt.replace(tzinfo=tz)).astimezone(UTC)
126
+ if dt < now + MIN_LEAD:
127
+ raise QueueError("Publish time must be at least 5 minutes in the future")
128
+ if dt > now + MAX_AHEAD:
129
+ raise QueueError("Publish time must be within 30 days")
130
+ return dt
131
+
132
+
133
+ def local_str(iso_utc: str, tz: ZoneInfo) -> str:
134
+ return datetime.fromisoformat(iso_utc).astimezone(tz).strftime("%a %Y-%m-%d %H:%M")
135
+
136
+
137
+ # ---------------------------------------------------------------- runner
138
+ class QueueRunner:
139
+ """Background loop inside the MCP server process: publishes due items once a minute."""
140
+
141
+ def __init__(self, get_service: Callable[[], Any], publisher_factory: Callable[[Any], Publisher] = Publisher):
142
+ self.get_service = get_service
143
+ self.publisher_factory = publisher_factory
144
+
145
+ async def run_forever(self) -> None:
146
+ log.info("Publishing queue runner started")
147
+ while True:
148
+ try:
149
+ await self.tick()
150
+ except Exception: # never let the loop die
151
+ log.exception("Queue tick failed")
152
+ await asyncio.sleep(TICK_S)
153
+
154
+ async def tick(self, now: datetime | None = None) -> list[dict[str, Any]]:
155
+ now = now or datetime.now(UTC)
156
+ try:
157
+ svc = self.get_service()
158
+ except Exception as exc: # no credentials yet
159
+ log.debug("Queue idle: %s", exc)
160
+ return []
161
+ done = []
162
+ for item in svc.storage.queue_due(now.isoformat(timespec="seconds")):
163
+ done.append(await self._handle(svc, item, now))
164
+ return done
165
+
166
+ async def _handle(self, svc, item: dict[str, Any], now: datetime) -> dict[str, Any]:
167
+ qid = item["id"]
168
+ at = datetime.fromisoformat(item["publish_at"])
169
+ st = svc.storage
170
+ if now - at > LATE_WINDOW:
171
+ st.queue_update(qid, status="missed", error=f"server was not running between {item['publish_at']} and "
172
+ f"+{LATE_WINDOW.seconds // 3600}h; nothing was posted")
173
+ return st.queue_get(qid)
174
+ try:
175
+ folder = resolve_folder(item["folder"])
176
+ if fingerprint(folder) != item["fingerprint"]:
177
+ st.queue_update(qid, status="blocked", error="files or caption changed after approval; nothing was posted")
178
+ return st.queue_get(qid)
179
+ except PublishError as exc:
180
+ st.queue_update(qid, status="blocked", error=str(exc))
181
+ return st.queue_get(qid)
182
+
183
+ async with PUBLISH_LOCK:
184
+ st.queue_update(qid, status="publishing")
185
+ pub = self.publisher_factory(svc)
186
+ try:
187
+ prepared = await pub.prepare(item["folder"])
188
+ st.queue_update(qid, creation_id=prepared["creation_id"])
189
+ result = await pub.publish(prepared["creation_id"])
190
+ except Exception as exc:
191
+ log.exception("Queued post %s failed", qid)
192
+ st.queue_update(qid, status="failed", error=str(exc))
193
+ return st.queue_get(qid)
194
+ st.queue_update(qid, status="published", permalink=result.get("permalink"), error=None)
195
+ log.info("Queued post %s published: %s", qid, result.get("permalink"))
196
+ return st.queue_get(qid)
creatorloop/publish.py ADDED
@@ -0,0 +1,417 @@
1
+ """Publishing images, carousels and Reels via the Instagram Graph API (content publishing).
2
+
3
+ Two explicit steps so nothing goes live by accident:
4
+ prepare(folder) -> validates and creates media containers, waits until Meta has processed them:
5
+ images/carousels: convert to JPEG, expose through a temporary tunnel;
6
+ Reels (one .mp4/.mov in the folder): resumable upload straight to Meta
7
+ (rupload.facebook.com), tunnel + video_url only as a fallback;
8
+ publish(creation_id) -> media_publish, returns the permalink.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import json
15
+ import logging
16
+ import re
17
+ import shutil
18
+ import struct
19
+ import tempfile
20
+ from collections.abc import Callable
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from creatorloop import paths
25
+ from creatorloop.graph_client import GraphAPIError
26
+ from creatorloop.tunnel import PublicTunnel
27
+
28
+ log = logging.getLogger(__name__)
29
+
30
+ CONTENT_ROOT = paths.content_root()
31
+ IMAGE_EXT = {".png", ".jpg", ".jpeg"}
32
+ MAX_ITEMS = 10
33
+ MAX_CAPTION = 2200
34
+ MAX_HASHTAGS = 30
35
+ MAX_MENTIONS = 20
36
+ MIN_RATIO, MAX_RATIO = 4 / 5, 1.91 # width / height accepted by Instagram feed
37
+ MAX_WIDTH = 1440
38
+ MAX_BYTES = 8 * 1024 * 1024
39
+ STATUS_POLL_S = 2.0
40
+ STATUS_TIMEOUT_S = 120.0
41
+
42
+ # Reels (developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/ig-user/media)
43
+ VIDEO_EXT = {".mp4", ".mov"}
44
+ MAX_VIDEO_BYTES = 300 * 1024 * 1024
45
+ MIN_VIDEO_S, MAX_VIDEO_S = 3.0, 15 * 60.0
46
+ MAX_VIDEO_WIDTH = 1920
47
+ REEL_POLL_S = 10.0 # video processing takes 30 s … minutes; keep the status calls low
48
+ REEL_TIMEOUT_S = 600.0
49
+ REEL_DEFAULTS = {"cover_ms": None, "share_to_feed": True, "trial": None}
50
+ TRIAL_STRATEGIES = {"MANUAL", "SS_PERFORMANCE"} # trial_params.graduation_strategy
51
+
52
+
53
+ class PublishError(RuntimeError):
54
+ pass
55
+
56
+
57
+ def resolve_folder(folder: str) -> Path:
58
+ """Folder name inside the content folder, or an absolute path inside it. Nothing outside is allowed."""
59
+ p = Path(folder)
60
+ candidate = (p if p.is_absolute() else CONTENT_ROOT / p).resolve()
61
+ if not candidate.is_relative_to(CONTENT_ROOT.resolve()):
62
+ raise PublishError(f"Folder must be inside the content folder: {CONTENT_ROOT}")
63
+ if not candidate.is_dir():
64
+ raise PublishError(f"Folder not found: {candidate}")
65
+ return candidate
66
+
67
+
68
+ def read_caption(folder: Path) -> str:
69
+ """caption.txt; everything after a line '---' is notes (e.g. alternative hooks) and is not published."""
70
+ f = folder / "caption.txt"
71
+ if not f.exists():
72
+ raise PublishError("No caption given and caption.txt not found in the folder")
73
+ text = f.read_text(encoding="utf-8")
74
+ return re.split(r"^---\s*$", text, maxsplit=1, flags=re.M)[0].strip()
75
+
76
+
77
+ def check_caption(caption: str) -> dict[str, int]:
78
+ stats = {
79
+ "length": len(caption),
80
+ "hashtags": len(re.findall(r"#\w+", caption)),
81
+ "mentions": len(re.findall(r"@[\w.]+", caption)),
82
+ }
83
+ problems = []
84
+ if stats["length"] > MAX_CAPTION:
85
+ problems.append(f"caption is {stats['length']} chars (max {MAX_CAPTION})")
86
+ if stats["hashtags"] > MAX_HASHTAGS:
87
+ problems.append(f"{stats['hashtags']} hashtags (max {MAX_HASHTAGS})")
88
+ if stats["mentions"] > MAX_MENTIONS:
89
+ problems.append(f"{stats['mentions']} mentions (max {MAX_MENTIONS})")
90
+ if problems:
91
+ raise PublishError("Caption rejected: " + "; ".join(problems))
92
+ return stats
93
+
94
+
95
+ def list_images(folder: Path) -> list[Path]:
96
+ images = sorted(p for p in folder.iterdir() if p.suffix.lower() in IMAGE_EXT and p.is_file())
97
+ if not images:
98
+ raise PublishError(f"No images (.png/.jpg) in {folder}")
99
+ if len(images) > MAX_ITEMS:
100
+ raise PublishError(f"{len(images)} images; a carousel takes at most {MAX_ITEMS}")
101
+ return images
102
+
103
+
104
+ def to_jpegs(images: list[Path], out_dir: Path) -> list[dict[str, Any]]:
105
+ """Instagram accepts only JPEG: convert, check aspect ratio, cap width and size."""
106
+ from PIL import Image # imported lazily: only publishing needs Pillow
107
+
108
+ result = []
109
+ for i, src in enumerate(images, 1):
110
+ with Image.open(src) as im:
111
+ w, h = im.size
112
+ ratio = w / h
113
+ if not (MIN_RATIO - 0.01 <= ratio <= MAX_RATIO + 0.01):
114
+ raise PublishError(f"{src.name}: aspect ratio {w}x{h} is outside 4:5 … 1.91:1")
115
+ rgb = im.convert("RGB")
116
+ if w > MAX_WIDTH:
117
+ rgb = rgb.resize((MAX_WIDTH, round(MAX_WIDTH / ratio)), Image.Resampling.LANCZOS)
118
+ dst = out_dir / f"{i:02d}.jpg"
119
+ for quality in (92, 85, 75):
120
+ rgb.save(dst, "JPEG", quality=quality, optimize=True, progressive=True)
121
+ if dst.stat().st_size <= MAX_BYTES:
122
+ break
123
+ else:
124
+ raise PublishError(f"{src.name}: still larger than 8 MB after compression")
125
+ result.append({"source": src.name, "file": dst.name, "size": f"{w}x{h}", "bytes": dst.stat().st_size})
126
+ return result
127
+
128
+
129
+ def list_videos(folder: Path) -> list[Path]:
130
+ return sorted(p for p in folder.iterdir() if p.suffix.lower() in VIDEO_EXT and p.is_file())
131
+
132
+
133
+ def _boxes(f, start: int, end: int):
134
+ """Iterate ISO-BMFF boxes: (type, payload_offset, payload_size, box_offset)."""
135
+ pos = start
136
+ while pos + 8 <= end:
137
+ f.seek(pos)
138
+ size, kind = struct.unpack(">I4s", f.read(8))
139
+ header = 8
140
+ if size == 1:
141
+ size = struct.unpack(">Q", f.read(8))[0]
142
+ header = 16
143
+ elif size == 0:
144
+ size = end - pos
145
+ if size < header:
146
+ break
147
+ yield kind.decode("latin-1"), pos + header, size - header, pos
148
+ pos += size
149
+
150
+
151
+ def mp4_info(path: Path) -> dict[str, Any]:
152
+ """Duration, size and 'moov before mdat' (fast start) from the MP4/MOV headers; no ffmpeg needed."""
153
+ end = path.stat().st_size
154
+ info: dict[str, Any] = {"bytes": end, "duration_s": None, "width": None, "height": None, "faststart": None}
155
+ with path.open("rb") as f:
156
+ top = list(_boxes(f, 0, end))
157
+ order = [k for k, *_ in top]
158
+ if "moov" not in order:
159
+ raise PublishError(f"{path.name}: not a valid MP4/MOV (no moov box)")
160
+ info["faststart"] = "mdat" not in order or order.index("moov") < order.index("mdat")
161
+ _, moov_at, moov_len, _ = top[order.index("moov")]
162
+ for kind, at, length, _ in _boxes(f, moov_at, moov_at + moov_len):
163
+ if kind == "mvhd":
164
+ f.seek(at)
165
+ version = f.read(1)[0]
166
+ if version == 1:
167
+ f.seek(at + 4 + 16)
168
+ timescale, duration = struct.unpack(">IQ", f.read(12))
169
+ else:
170
+ f.seek(at + 4 + 8)
171
+ timescale, duration = struct.unpack(">II", f.read(8))
172
+ info["duration_s"] = round(duration / timescale, 2) if timescale else None
173
+ elif kind == "trak" and info["width"] is None:
174
+ for sub, sat, slen, _ in _boxes(f, at, at + length):
175
+ if sub == "tkhd":
176
+ f.seek(sat + slen - 8)
177
+ w, h = struct.unpack(">II", f.read(8))
178
+ if w and h:
179
+ info["width"], info["height"] = w >> 16, h >> 16
180
+ return info
181
+
182
+
183
+ def check_video(path: Path) -> dict[str, Any]:
184
+ info = mp4_info(path)
185
+ problems = []
186
+ if info["bytes"] > MAX_VIDEO_BYTES:
187
+ problems.append(f"{info['bytes'] / 2**20:.0f} MB (max 300 MB)")
188
+ d = info["duration_s"]
189
+ if d is not None and not (MIN_VIDEO_S <= d <= MAX_VIDEO_S):
190
+ problems.append(f"duration {d:.1f}s (allowed 3 s … 15 min)")
191
+ if info["width"] and info["width"] > MAX_VIDEO_WIDTH:
192
+ problems.append(f"width {info['width']} px (max {MAX_VIDEO_WIDTH})")
193
+ if not info["faststart"]:
194
+ problems.append("moov atom must be at the front: re-mux with `ffmpeg -i in.mp4 -c copy -movflags +faststart out.mp4`")
195
+ if problems:
196
+ raise PublishError(f"{path.name} rejected: " + "; ".join(problems))
197
+ return {"file": path.name, "mb": round(info["bytes"] / 2**20, 1), "duration_s": d,
198
+ "size": f"{info['width']}x{info['height']}" if info["width"] else None}
199
+
200
+
201
+ def read_reel_settings(folder: Path) -> dict[str, Any]:
202
+ """Optional reel.json: {"cover_ms": 1667, "share_to_feed": true, "trial": null | "MANUAL" | "SS_PERFORMANCE"}.
203
+
204
+ trial = Trial Reel: shown to non-followers first. MANUAL = you promote it to followers in the app,
205
+ SS_PERFORMANCE = Instagram promotes it automatically if it performs well.
206
+ """
207
+ settings = dict(REEL_DEFAULTS)
208
+ f = folder / "reel.json"
209
+ if f.exists():
210
+ try:
211
+ data = json.loads(f.read_text(encoding="utf-8"))
212
+ except ValueError as exc:
213
+ raise PublishError(f"reel.json is not valid JSON: {exc}") from exc
214
+ unknown = set(data) - set(REEL_DEFAULTS)
215
+ if unknown:
216
+ raise PublishError(f"reel.json: unknown keys {sorted(unknown)} (allowed: {', '.join(REEL_DEFAULTS)})")
217
+ settings.update(data)
218
+ if settings["cover_ms"] is not None and (not isinstance(settings["cover_ms"], int) or settings["cover_ms"] < 0):
219
+ raise PublishError("reel.json: cover_ms must be a non-negative integer (milliseconds)")
220
+ if settings["trial"] is not None and settings["trial"] not in TRIAL_STRATEGIES:
221
+ raise PublishError(f"reel.json: trial must be null, {' or '.join(sorted(TRIAL_STRATEGIES))}")
222
+ return settings
223
+
224
+
225
+ class Publisher:
226
+ def __init__(self, service, tunnel_factory: Callable[[Path], Any] = PublicTunnel) -> None:
227
+ self.svc = service
228
+ self.client = service.client
229
+ self.storage = service.storage
230
+ self.tunnel_factory = tunnel_factory
231
+
232
+ @property
233
+ def uid(self) -> str:
234
+ return self.svc.uid
235
+
236
+ async def limit(self) -> dict[str, Any]:
237
+ body = await self.client.get(f"/{self.uid}/content_publishing_limit", {"fields": "config,quota_usage"})
238
+ data = (body.get("data") or [{}])[0]
239
+ total = (data.get("config") or {}).get("quota_total")
240
+ used = data.get("quota_usage")
241
+ return {"used_24h": used, "limit_24h": total, "remaining": (total - used) if total and used is not None else None}
242
+
243
+ async def _wait_finished(self, container_id: str, *, poll: float | None = None, timeout: float | None = None) -> str:
244
+ poll = STATUS_POLL_S if poll is None else poll
245
+ timeout = STATUS_TIMEOUT_S if timeout is None else timeout
246
+ deadline = asyncio.get_running_loop().time() + timeout
247
+ while True:
248
+ body = await self.client.get(f"/{container_id}", {"fields": "status_code,status"})
249
+ code = body.get("status_code")
250
+ if code in ("FINISHED", "PUBLISHED"):
251
+ return code
252
+ if code in ("ERROR", "EXPIRED"):
253
+ raise PublishError(f"Container {container_id}: {code} {body.get('status', '')}".strip())
254
+ if asyncio.get_running_loop().time() > deadline:
255
+ raise PublishError(f"Container {container_id} still {code} after {timeout:.0f}s")
256
+ await asyncio.sleep(poll)
257
+
258
+ async def prepare(
259
+ self, folder: str, caption: str | None = None, on_stage: Callable[[str], None] = lambda _: None
260
+ ) -> dict[str, Any]:
261
+ on_stage("validating")
262
+ path = resolve_folder(folder)
263
+ caption = caption if caption is not None else read_caption(path)
264
+ stats = check_caption(caption)
265
+ videos = list_videos(path)
266
+ if len(videos) > 1:
267
+ raise PublishError(f"{len(videos)} videos in {path.name}; a Reel takes exactly one .mp4/.mov")
268
+ if videos:
269
+ return await self._prepare_reel(path, videos[0], caption, stats, on_stage)
270
+ images = list_images(path)
271
+
272
+ quota = await self._check_quota()
273
+
274
+ work = Path(tempfile.mkdtemp(prefix="igpub-"))
275
+ try:
276
+ on_stage("converting to JPEG")
277
+ files = await asyncio.to_thread(to_jpegs, images, work)
278
+ on_stage("starting tunnel")
279
+ async with self.tunnel_factory(work) as base_url:
280
+ on_stage(f"creating containers via {base_url}")
281
+ children: list[str] = []
282
+ if len(files) == 1:
283
+ body = await self.client.post(
284
+ f"/{self.uid}/media", {"image_url": f"{base_url}/{files[0]['file']}", "caption": caption}
285
+ )
286
+ creation_id = body["id"]
287
+ else:
288
+ for f in files:
289
+ body = await self.client.post(
290
+ f"/{self.uid}/media", {"image_url": f"{base_url}/{f['file']}", "is_carousel_item": "true"}
291
+ )
292
+ children.append(body["id"])
293
+ on_stage("waiting for Meta to process slides")
294
+ for child in children:
295
+ await self._wait_finished(child)
296
+ body = await self.client.post(
297
+ f"/{self.uid}/media",
298
+ {"media_type": "CAROUSEL", "children": ",".join(children), "caption": caption},
299
+ )
300
+ creation_id = body["id"]
301
+ # Meta copies the media while processing; once FINISHED the tunnel is no longer needed
302
+ on_stage("waiting for the post container")
303
+ status = await self._wait_finished(creation_id)
304
+ except GraphAPIError as exc:
305
+ raise PublishError(f"Graph API rejected the media: {exc}") from exc
306
+ finally:
307
+ shutil.rmtree(work, ignore_errors=True)
308
+
309
+ kind = "CAROUSEL" if len(files) > 1 else "IMAGE"
310
+ self.storage.save_publication(creation_id, str(path), caption, len(files), kind)
311
+ return {
312
+ "creation_id": creation_id,
313
+ "status": status,
314
+ "type": kind,
315
+ "slides": files,
316
+ "caption_preview": caption[:300] + ("…" if len(caption) > 300 else ""),
317
+ "caption_stats": stats,
318
+ "publishing_limit": quota,
319
+ "next": "Show this to the user. Call publish_media(creation_id) ONLY after an explicit 'publish' from them. "
320
+ "Containers expire after 24h.",
321
+ }
322
+
323
+ async def _check_quota(self) -> dict[str, Any]:
324
+ quota = await self.limit()
325
+ if quota["remaining"] is not None and quota["remaining"] <= 0:
326
+ raise PublishError(f"Publishing limit reached: {quota['used_24h']}/{quota['limit_24h']} in 24h")
327
+ return quota
328
+
329
+ async def _prepare_reel(
330
+ self, path: Path, video: Path, caption: str, stats: dict[str, int], on_stage: Callable[[str], None]
331
+ ) -> dict[str, Any]:
332
+ info = check_video(video)
333
+ settings = read_reel_settings(path)
334
+ quota = await self._check_quota()
335
+ params: dict[str, Any] = {
336
+ "media_type": "REELS",
337
+ "caption": caption,
338
+ "share_to_feed": "true" if settings["share_to_feed"] else "false",
339
+ "thumb_offset": settings["cover_ms"],
340
+ }
341
+ if settings["trial"]:
342
+ # Trial Reels go to non-followers first, so feed sharing does not apply
343
+ params.pop("share_to_feed")
344
+ params["trial_params"] = json.dumps({"graduation_strategy": settings["trial"]})
345
+ method = "resumable upload"
346
+ try:
347
+ on_stage("creating REELS container")
348
+ body = await self.client.post(f"/{self.uid}/media", {**params, "upload_type": "resumable"})
349
+ creation_id = body["id"]
350
+ on_stage(f"uploading video ({info['mb']} MB)")
351
+ await self.client.upload_video(creation_id, video)
352
+ except GraphAPIError as exc:
353
+ # Fallback: let Meta fetch the file from a temporary public URL (same tunnel as for images)
354
+ log.warning("Resumable upload failed (%s); falling back to video_url via tunnel", exc)
355
+ method = f"tunnel video_url (resumable failed: {exc})"
356
+ creation_id = await self._reel_via_tunnel(video, params, on_stage)
357
+ status = "FINISHED"
358
+ else:
359
+ on_stage("waiting for Meta to process the video")
360
+ status = await self._wait_finished(creation_id, poll=REEL_POLL_S, timeout=REEL_TIMEOUT_S)
361
+
362
+ self.storage.save_publication(creation_id, str(path), caption, 1, "REELS")
363
+ ignored = sorted(p.name for p in path.iterdir() if p.suffix.lower() in IMAGE_EXT)
364
+ return {
365
+ "creation_id": creation_id,
366
+ "status": status,
367
+ "type": "REELS",
368
+ "video": info,
369
+ "cover_ms": settings["cover_ms"],
370
+ "share_to_feed": None if settings["trial"] else settings["share_to_feed"],
371
+ "trial": settings["trial"],
372
+ "upload": method,
373
+ "ignored_files": ignored,
374
+ "caption_preview": caption[:300] + ("…" if len(caption) > 300 else ""),
375
+ "caption_stats": stats,
376
+ "publishing_limit": quota,
377
+ "next": "Show this to the user. Call publish_media(creation_id) ONLY after an explicit 'publish' from them. "
378
+ "Containers expire after 24h.",
379
+ }
380
+
381
+ async def _reel_via_tunnel(self, video: Path, params: dict[str, Any], on_stage: Callable[[str], None]) -> str:
382
+ work = Path(tempfile.mkdtemp(prefix="igreel-"))
383
+ try:
384
+ name = "reel" + video.suffix.lower()
385
+ await asyncio.to_thread(shutil.copyfile, video, work / name)
386
+ on_stage("starting tunnel")
387
+ async with self.tunnel_factory(work) as base_url:
388
+ on_stage("creating REELS container (video_url)")
389
+ body = await self.client.post(f"/{self.uid}/media", {**params, "video_url": f"{base_url}/{name}"})
390
+ creation_id = body["id"]
391
+ on_stage("waiting for Meta to download and process the video")
392
+ await self._wait_finished(creation_id, poll=REEL_POLL_S, timeout=REEL_TIMEOUT_S)
393
+ return creation_id
394
+ except GraphAPIError as exc:
395
+ raise PublishError(f"Graph API rejected the Reel: {exc}") from exc
396
+ finally:
397
+ shutil.rmtree(work, ignore_errors=True)
398
+
399
+ async def publish(self, creation_id: str) -> dict[str, Any]:
400
+ record = self.storage.get_publication(creation_id)
401
+ if record is None:
402
+ raise PublishError("Unknown creation_id: prepare it with prepare_post first")
403
+ if record["status"] == "published":
404
+ return {"already_published": True, **record}
405
+ await self._wait_finished(creation_id)
406
+ try:
407
+ body = await self.client.post(f"/{self.uid}/media_publish", {"creation_id": creation_id})
408
+ except GraphAPIError as exc:
409
+ self.storage.update_publication(creation_id, status="failed", error=str(exc))
410
+ raise PublishError(f"Publishing failed: {exc}") from exc
411
+ media_id = body["id"]
412
+ info = await self.client.get(f"/{media_id}", {"fields": "permalink,timestamp,media_type"})
413
+ self.storage.update_publication(
414
+ creation_id, status="published", media_id=media_id, permalink=info.get("permalink")
415
+ )
416
+ self.storage.cache_clear("me:") # profile / media lists changed
417
+ return {"published": True, "media_id": media_id, "permalink": info.get("permalink"), "timestamp": info.get("timestamp")}