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.
- creatorloop/__init__.py +13 -0
- creatorloop/__main__.py +3 -0
- creatorloop/analytics.py +577 -0
- creatorloop/auth_cli.py +133 -0
- creatorloop/comments.py +111 -0
- creatorloop/config.py +110 -0
- creatorloop/env.example +33 -0
- creatorloop/graph_client.py +224 -0
- creatorloop/instagram.py +461 -0
- creatorloop/paths.py +67 -0
- creatorloop/post_queue.py +196 -0
- creatorloop/publish.py +417 -0
- creatorloop/refs.py +188 -0
- creatorloop/reviews.py +116 -0
- creatorloop/server.py +816 -0
- creatorloop/setup_cli.py +175 -0
- creatorloop/storage.py +464 -0
- creatorloop/tunnel.py +169 -0
- creatorloop-0.1.0.dist-info/METADATA +254 -0
- creatorloop-0.1.0.dist-info/RECORD +23 -0
- creatorloop-0.1.0.dist-info/WHEEL +4 -0
- creatorloop-0.1.0.dist-info/entry_points.txt +4 -0
- creatorloop-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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")}
|