socialcli 0.3.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.

Potentially problematic release.


This version of socialcli might be problematic. Click here for more details.

Files changed (224) hide show
  1. socialcli-0.3.0.dist-info/METADATA +168 -0
  2. socialcli-0.3.0.dist-info/RECORD +224 -0
  3. socialcli-0.3.0.dist-info/WHEEL +4 -0
  4. socialcli-0.3.0.dist-info/entry_points.txt +3 -0
  5. socialcli-0.3.0.dist-info/licenses/LICENSE +21 -0
  6. socialctl/__init__.py +0 -0
  7. socialctl/__main__.py +6 -0
  8. socialctl/adapters/__init__.py +6 -0
  9. socialctl/adapters/base.py +68 -0
  10. socialctl/adapters/errores.py +56 -0
  11. socialctl/adapters/facebook.py +180 -0
  12. socialctl/adapters/instagram.py +442 -0
  13. socialctl/adapters/tiktok.py +535 -0
  14. socialctl/adapters/youtube.py +331 -0
  15. socialctl/agent_errors.py +176 -0
  16. socialctl/agent_guide.py +23 -0
  17. socialctl/approval_cli.py +27 -0
  18. socialctl/auth.py +139 -0
  19. socialctl/authflow.py +344 -0
  20. socialctl/brands.py +194 -0
  21. socialctl/capabilities.py +57 -0
  22. socialctl/capabilities.yml +5945 -0
  23. socialctl/cli.py +1766 -0
  24. socialctl/connection_service/__init__.py +0 -0
  25. socialctl/connection_service/__main__.py +33 -0
  26. socialctl/connection_service/app.py +496 -0
  27. socialctl/connection_service/assets/PLATFORM-LOGOS-LICENSE.txt +50 -0
  28. socialctl/connection_service/assets/connection.css +75 -0
  29. socialctl/connection_service/assets/facebook.webp +0 -0
  30. socialctl/connection_service/assets/instagram.svg +194 -0
  31. socialctl/connection_service/assets/socialcli-logo.png +0 -0
  32. socialctl/connection_service/assets/tiktok.png +0 -0
  33. socialctl/connection_service/assets/youtube.svg +1 -0
  34. socialctl/connection_service/landing.py +146 -0
  35. socialctl/connection_service/legacy.py +48 -0
  36. socialctl/connection_service/peer.py +13 -0
  37. socialctl/connection_service/providers.py +324 -0
  38. socialctl/connection_service/settings.py +76 -0
  39. socialctl/connection_service/store.py +124 -0
  40. socialctl/connections/__init__.py +0 -0
  41. socialctl/connections/account_cli.py +438 -0
  42. socialctl/connections/cli.py +379 -0
  43. socialctl/connections/client.py +126 -0
  44. socialctl/connections/credential_cli.py +224 -0
  45. socialctl/connections/credential_store.py +49 -0
  46. socialctl/connections/file_store.py +193 -0
  47. socialctl/connections/keychain.py +78 -0
  48. socialctl/connections/meta_user.py +81 -0
  49. socialctl/connections/rotation_cli.py +116 -0
  50. socialctl/connections/saas_client.py +91 -0
  51. socialctl/connections/temporary_media.py +62 -0
  52. socialctl/delivery_options.py +70 -0
  53. socialctl/engagement.py +200 -0
  54. socialctl/engagement_cli.py +50 -0
  55. socialctl/executor.py +135 -0
  56. socialctl/formatter.py +291 -0
  57. socialctl/hosted_media.py +152 -0
  58. socialctl/identity.py +200 -0
  59. socialctl/inbox.py +298 -0
  60. socialctl/inbox_cli.py +46 -0
  61. socialctl/inventory.py +155 -0
  62. socialctl/inventory_cli.py +171 -0
  63. socialctl/inventory_sources.py +101 -0
  64. socialctl/management/__init__.py +1 -0
  65. socialctl/management/batch_cli.py +115 -0
  66. socialctl/management/batches.py +325 -0
  67. socialctl/management/changes.py +485 -0
  68. socialctl/management/cli.py +227 -0
  69. socialctl/management/comments_cli.py +352 -0
  70. socialctl/management/community_batch_cli.py +63 -0
  71. socialctl/management/community_batches.py +294 -0
  72. socialctl/management/community_changes.py +370 -0
  73. socialctl/management/community_cli.py +142 -0
  74. socialctl/management/community_schema.py +119 -0
  75. socialctl/management/facebook_groups.py +1118 -0
  76. socialctl/management/groups_cli.py +274 -0
  77. socialctl/management/inbox_readers.py +87 -0
  78. socialctl/management/meta_changes.py +197 -0
  79. socialctl/management/meta_cli.py +342 -0
  80. socialctl/management/meta_client.py +631 -0
  81. socialctl/management/meta_comment_inbox.py +565 -0
  82. socialctl/management/meta_comments.py +587 -0
  83. socialctl/management/meta_errors.py +59 -0
  84. socialctl/management/meta_pagination.py +52 -0
  85. socialctl/management/meta_schema.py +128 -0
  86. socialctl/management/meta_webhooks.py +147 -0
  87. socialctl/management/metadata_parts.py +144 -0
  88. socialctl/management/owned_assets.py +85 -0
  89. socialctl/management/owned_changes.py +470 -0
  90. socialctl/management/owned_cli.py +128 -0
  91. socialctl/management/owned_reorder.py +62 -0
  92. socialctl/management/owned_schema.py +368 -0
  93. socialctl/management/reply_context.py +24 -0
  94. socialctl/management/reply_evidence.py +80 -0
  95. socialctl/management/resource_changes.py +407 -0
  96. socialctl/management/resource_cli.py +136 -0
  97. socialctl/management/snippet_readback.py +26 -0
  98. socialctl/management/stories.py +713 -0
  99. socialctl/management/stories_cli.py +172 -0
  100. socialctl/management/youtube.py +323 -0
  101. socialctl/management/youtube_community.py +326 -0
  102. socialctl/management/youtube_metadata.py +122 -0
  103. socialctl/management/youtube_owned.py +259 -0
  104. socialctl/management/youtube_resources.py +305 -0
  105. socialctl/media.py +269 -0
  106. socialctl/media_cli.py +183 -0
  107. socialctl/media_ingress.py +211 -0
  108. socialctl/media_preflight.py +114 -0
  109. socialctl/media_registry.py +301 -0
  110. socialctl/media_requirements.py +52 -0
  111. socialctl/metricas/__init__.py +6 -0
  112. socialctl/metricas/almacen.py +133 -0
  113. socialctl/metricas/analytics_changes.py +295 -0
  114. socialctl/metricas/analytics_cli.py +191 -0
  115. socialctl/metricas/analytics_client.py +304 -0
  116. socialctl/metricas/analytics_ingest.py +260 -0
  117. socialctl/metricas/analytics_schema.py +174 -0
  118. socialctl/metricas/base.py +147 -0
  119. socialctl/metricas/facebook.py +418 -0
  120. socialctl/metricas/insights.py +227 -0
  121. socialctl/metricas/instagram.py +133 -0
  122. socialctl/metricas/modelos.py +87 -0
  123. socialctl/metricas/piezas.py +134 -0
  124. socialctl/metricas/reach.py +94 -0
  125. socialctl/metricas/reporting_client.py +112 -0
  126. socialctl/metricas/resumen.py +251 -0
  127. socialctl/metricas/slugs.py +89 -0
  128. socialctl/metricas/tiktok.py +185 -0
  129. socialctl/metricas/youtube.py +403 -0
  130. socialctl/migration_cli.py +60 -0
  131. socialctl/migration_files.py +68 -0
  132. socialctl/models.py +150 -0
  133. socialctl/native_schedule/__init__.py +13 -0
  134. socialctl/native_schedule/api_delivery.py +15 -0
  135. socialctl/native_schedule/api_reconcile.py +40 -0
  136. socialctl/native_schedule/approval.py +160 -0
  137. socialctl/native_schedule/cadence.py +73 -0
  138. socialctl/native_schedule/cli.py +510 -0
  139. socialctl/native_schedule/dispatcher.py +120 -0
  140. socialctl/native_schedule/facebook.py +112 -0
  141. socialctl/native_schedule/facebook_reels.py +140 -0
  142. socialctl/native_schedule/first_comment.py +181 -0
  143. socialctl/native_schedule/handoff.py +193 -0
  144. socialctl/native_schedule/instagram_delivery.py +224 -0
  145. socialctl/native_schedule/instagram_runtime.py +66 -0
  146. socialctl/native_schedule/inventory.py +70 -0
  147. socialctl/native_schedule/migration.py +310 -0
  148. socialctl/native_schedule/models.py +103 -0
  149. socialctl/native_schedule/reconcile.py +83 -0
  150. socialctl/native_schedule/remote_data.py +177 -0
  151. socialctl/native_schedule/report.py +151 -0
  152. socialctl/native_schedule/store.py +560 -0
  153. socialctl/native_schedule/verification.py +32 -0
  154. socialctl/native_schedule/youtube.py +339 -0
  155. socialctl/operation_output.py +87 -0
  156. socialctl/plantillas/accounts.yml +33 -0
  157. socialctl/plantillas/brand.md +95 -0
  158. socialctl/plantillas/estrategia.md +64 -0
  159. socialctl/postfile.py +240 -0
  160. socialctl/preflight.py +73 -0
  161. socialctl/publication_policy.py +34 -0
  162. socialctl/publication_status.py +75 -0
  163. socialctl/publication_steps.py +398 -0
  164. socialctl/publish_output.py +96 -0
  165. socialctl/publisher.py +445 -0
  166. socialctl/queue_migration.py +302 -0
  167. socialctl/read_budget.py +224 -0
  168. socialctl/read_reports.py +91 -0
  169. socialctl/rutas.py +103 -0
  170. socialctl/saas/__init__.py +1 -0
  171. socialctl/saas/access.py +75 -0
  172. socialctl/saas/auth.py +213 -0
  173. socialctl/saas/backup.py +124 -0
  174. socialctl/saas/billing.py +327 -0
  175. socialctl/saas/billing_enrollment.py +1237 -0
  176. socialctl/saas/billing_enrollment_schema.py +519 -0
  177. socialctl/saas/billing_inventory.py +775 -0
  178. socialctl/saas/billing_reconcile.py +127 -0
  179. socialctl/saas/billing_routes.py +27 -0
  180. socialctl/saas/budget.py +66 -0
  181. socialctl/saas/connections.py +374 -0
  182. socialctl/saas/consent.py +52 -0
  183. socialctl/saas/lifecycle.py +341 -0
  184. socialctl/saas/mail.py +89 -0
  185. socialctl/saas/maintenance.py +68 -0
  186. socialctl/saas/meta_user.py +298 -0
  187. socialctl/saas/meta_webhooks.py +358 -0
  188. socialctl/saas/migration.py +107 -0
  189. socialctl/saas/models.py +8 -0
  190. socialctl/saas/operations.py +102 -0
  191. socialctl/saas/pages.py +126 -0
  192. socialctl/saas/pairing.py +158 -0
  193. socialctl/saas/permissions.py +57 -0
  194. socialctl/saas/reminders.py +128 -0
  195. socialctl/saas/renewal.py +74 -0
  196. socialctl/saas/restore_guard.py +229 -0
  197. socialctl/saas/revocation.py +97 -0
  198. socialctl/saas/rotation.py +114 -0
  199. socialctl/saas/routes.py +221 -0
  200. socialctl/saas/settings.py +34 -0
  201. socialctl/saas/store.py +106 -0
  202. socialctl/saas/temporary_media.py +245 -0
  203. socialctl/saas/webhooks.py +76 -0
  204. socialctl/schedule_remote.py +262 -0
  205. socialctl/schedule_sqlite.py +44 -0
  206. socialctl/scheduler.py +581 -0
  207. socialctl/scheduler_launchd.py +319 -0
  208. socialctl/scheduler_systemd.py +167 -0
  209. socialctl/source_links.py +119 -0
  210. socialctl/ui/__init__.py +1 -0
  211. socialctl/ui/assets/agent-manual.md +850 -0
  212. socialctl/ui/assets/agents.md +27 -0
  213. socialctl/ui/assets/components.css +20 -0
  214. socialctl/ui/assets/connection-layout.css +53 -0
  215. socialctl/ui/assets/otp.template.html +10 -0
  216. socialctl/ui/assets/tokens.css +1 -0
  217. socialctl/ui/assets/trial.template.html +11 -0
  218. socialctl/ui/assets/web-layout.css +38 -0
  219. socialctl/ui/email/otp.html +10 -0
  220. socialctl/ui/email/trial.html +11 -0
  221. socialctl/upload_consent.py +13 -0
  222. socialctl/workspace.py +8 -0
  223. socialctl/youtube_data.py +471 -0
  224. socialctl/youtube_data_service.py +225 -0
@@ -0,0 +1,407 @@
1
+ """Separate durable schema for exact supplied YouTube assets and explicit deletion."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import hmac
6
+ import json
7
+ import os
8
+ import re
9
+ import stat
10
+ import tempfile
11
+ import uuid
12
+ import xml.etree.ElementTree as ET
13
+ from contextlib import ExitStack, contextmanager
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+ from typing import Literal
17
+
18
+ from pydantic import BaseModel, ConfigDict, Field
19
+
20
+ from socialctl.management.changes import ApprovalMismatch, ChangeError, ChangeStore, now_utc
21
+ from socialctl.management.youtube_resources import (
22
+ MAX_CAPTION, ResourceError, ResourceRejected, ResourceConflict, ResourceUncertain, resource_id,
23
+ thumbnail_snapshot, validate_thumbnail_snapshot,
24
+ )
25
+
26
+
27
+ def digest(data):
28
+ return hashlib.sha256(data).hexdigest()
29
+
30
+
31
+ def caption_format(path, data):
32
+ extension = path.suffix.lower().lstrip(".")
33
+ opaque = {"cap", "tds", "cin", "stl", "asc"}
34
+ if extension in opaque:
35
+ return "application/octet-stream", extension, "extension_only/provider_validation_required"
36
+ allowed = {"srt", "vtt", "sbv", "sub", "mpsub", "lrc", "smi", "sami", "rt", "ttml", "dfxp", "scc"}
37
+ if extension not in allowed:
38
+ raise ResourceError("caption extension undocumented/unsupported")
39
+ try:
40
+ text = data.decode("utf-8-sig")
41
+ except UnicodeDecodeError:
42
+ raise ResourceError("text format requires supplied UTF-8; no conversion") from None
43
+ if "\x00" in text or re.search(r"<!\s*(DOCTYPE|ENTITY)", text, re.I):
44
+ raise ResourceError("captions: unsupported text or XML declarations")
45
+ patterns = {
46
+ "srt": r"(?m)^\d+\r?\n\d{2}:\d{2}:\d{2},\d{3}\s+-->\s+\d{2}:\d{2}:\d{2},\d{3}",
47
+ "vtt": r"\AWEBVTT(?:\s|$)[\s\S]*\d{2}:\d{2}\.\d{3}\s+-->\s+\d{2}:\d{2}",
48
+ "sbv": r"(?m)^\d+:\d{2}:\d{2}\.\d{2,3},\d+:\d{2}:\d{2}\.\d{2,3}",
49
+ "sub": r"(?m)^\d+:\d{2}:\d{2}\.\d{2,3},\d+:\d{2}:\d{2}\.\d{2,3}",
50
+ "mpsub": r"(?m)^FORMAT=(?:TIME|\d+(?:\.\d+)?)\s*$[\s\S]*^\d+(?:\.\d+)?\s+\d+(?:\.\d+)?\s*$",
51
+ "lrc": r"\[\d{2}:\d{2}(?:\.\d{2,3})?\]",
52
+ "smi": r"(?is)<sami\b[\s\S]*<sync\s+start\s*=\s*[\"']?\d+",
53
+ "sami": r"(?is)<sami\b[\s\S]*<sync\s+start\s*=\s*[\"']?\d+",
54
+ "rt": r"(?is)<window\b[\s\S]*<time\s+begin\s*=",
55
+ "scc": r"\AScenarist_SCC V1\.0[\s\S]*\d{2}:\d{2}:\d{2}[:;]\d{2}\s+[0-9a-fA-F]{4}",
56
+ }
57
+ if extension in {"ttml", "dfxp"}:
58
+ try:
59
+ root = ET.fromstring(text)
60
+ valid = root.tag.rsplit("}", 1)[-1] == "tt" and any(
61
+ el.tag.rsplit("}", 1)[-1] == "p" and el.get("begin") is not None
62
+ and (el.get("end") is not None or el.get("dur") is not None) for el in root.iter())
63
+ except ET.ParseError:
64
+ valid = False
65
+ else:
66
+ valid = re.search(patterns[extension], text) is not None
67
+ if not valid:
68
+ raise ResourceError("captions: unrecognized structure/timestamps; synchronization is not generated")
69
+ mime = "text/xml" if extension in {"ttml", "dfxp"} else "application/octet-stream"
70
+ return mime, extension, "basic_structure/provider_validation_required"
71
+
72
+
73
+ def inspect_asset(path: Path, *, thumbnail: bool):
74
+ """Read a bounded regular supplied file, identify format, keep its exact bytes."""
75
+ path = Path(path).absolute()
76
+ maximum = 2 * 1024 * 1024 if thumbnail else MAX_CAPTION
77
+ try:
78
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
79
+ with os.fdopen(fd, "rb") as source:
80
+ before = os.fstat(source.fileno())
81
+ if not stat.S_ISREG(before.st_mode) or not 0 < before.st_size <= maximum:
82
+ raise ResourceError(f"file is not regular, empty or exceeds {maximum} bytes")
83
+ data = source.read(maximum + 1)
84
+ after = os.fstat(source.fileno())
85
+ if (before.st_size, before.st_mtime_ns, before.st_ctime_ns) != (after.st_size, after.st_mtime_ns, after.st_ctime_ns) or len(data) != before.st_size:
86
+ raise ResourceError("file changed during read")
87
+ except OSError:
88
+ raise ResourceError("failed to read regular file without symlinks") from None
89
+ if thumbnail:
90
+ if data.startswith(b"\x89PNG\r\n\x1a\n") and data[12:16] == b"IHDR" and data[-8:] == b"IEND\xaeB`\x82":
91
+ mime, format_name = "image/png", "png"
92
+ elif data.startswith(b"\xff\xd8\xff") and data.endswith(b"\xff\xd9"):
93
+ mime, format_name = "image/jpeg", "jpeg"
94
+ else:
95
+ raise ResourceError("thumbnail: PNG or JPEG bytes required; file is not converted")
96
+ validation = "container_signature/provider_validation_required"
97
+ else:
98
+ mime, format_name, validation = caption_format(path, data)
99
+ return {"path": str(path), "size": len(data), "sha256": digest(data), "mime": mime,
100
+ "format": format_name, "local_format_validation": validation}, data
101
+
102
+
103
+ def save_download(path, data):
104
+ """Atomic private no-clobber write of exact downloaded response bytes."""
105
+ path = Path(path).absolute()
106
+ temporary = None
107
+ try:
108
+ fd, temporary = tempfile.mkstemp(prefix="caption-", suffix=".tmp", dir=path.parent)
109
+ with os.fdopen(fd, "wb") as stream:
110
+ stream.write(data)
111
+ stream.flush()
112
+ os.fsync(stream.fileno())
113
+ os.link(temporary, path, follow_symlinks=False)
114
+ ChangeStore._sync_directory(path.parent)
115
+ except OSError:
116
+ raise ResourceError("failed to save private download; existing files are not replaced") from None
117
+ finally:
118
+ if temporary is not None:
119
+ os.unlink(temporary)
120
+
121
+
122
+ class ResourceChange(BaseModel):
123
+ model_config = ConfigDict(extra="forbid")
124
+ id: str
125
+ version: Literal[1] = 1
126
+ kind: Literal["youtube-supplied-resource"] = "youtube-supplied-resource"
127
+ action: Literal["thumbnail-set", "caption-insert", "caption-update", "caption-delete"]
128
+ target_brand: str
129
+ target_account: str
130
+ video_id: str
131
+ track_id: str | None = None
132
+ language: str | None = None
133
+ name: str | None = None
134
+ draft: bool | None = None
135
+ draft_changed: bool = False
136
+ asset: dict | None = None
137
+ before: dict
138
+ backup: dict
139
+ fingerprint: str = ""
140
+ created_at: datetime
141
+ updated_at: datetime
142
+ status: Literal["proposed", "applying", "uncertain", "failed", "conflict", "verified", "accepted_unverifiable"] = "proposed"
143
+ verified: bool = False
144
+ result_id: str | None = None
145
+ receipt: dict | None = None
146
+ journal: list[dict] = Field(default_factory=list)
147
+
148
+
149
+ def fingerprint(change):
150
+ raw = change.model_dump(mode="json")
151
+ immutable = {k: v for k, v in raw.items() if k not in {
152
+ "fingerprint", "updated_at", "status", "verified", "result_id", "receipt", "journal"}}
153
+ return digest(json.dumps(immutable, sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode())
154
+
155
+
156
+ class ResourceStore(ChangeStore):
157
+ model_type = ResourceChange
158
+ fingerprint_for = staticmethod(fingerprint)
159
+
160
+ def __init__(self, brand_root):
161
+ self.root = Path(brand_root) / ".socialctl" / "youtube-resources"
162
+
163
+ def _ensure_root(self):
164
+ for directory in (self.root.parent, self.root):
165
+ if directory.is_symlink():
166
+ raise ResourceError("private storage does not accept symlinks")
167
+ directory.mkdir(mode=0o700, exist_ok=True)
168
+ self._sync_directory(directory.parent)
169
+
170
+ @contextmanager
171
+ def apply_lock(self, change_id):
172
+ self._ensure_root()
173
+ if (self.root / f"{self._validate_id(change_id)}.lock").is_symlink():
174
+ raise ResourceError("private lock does not accept symlinks")
175
+ with super().apply_lock(change_id):
176
+ yield
177
+
178
+ def backup_path(self, change_id):
179
+ return self.root / f"{self._validate_id(change_id)}.caption-original"
180
+
181
+ def save_backup(self, change_id, data):
182
+ self._ensure_root()
183
+ save_download(self.backup_path(change_id), data)
184
+
185
+
186
+ def _event(change, name, **details):
187
+ change.updated_at = now_utc()
188
+ change.journal.append({"at": change.updated_at.isoformat(), "event": name, **details})
189
+
190
+
191
+ def _identity(client, change):
192
+ if client.brand.nombre != change.target_brand or client.configured_channel_id != change.target_account:
193
+ raise ResourceError("brand/account does not match proposal")
194
+ return client.inspect(change.video_id)
195
+
196
+
197
+ def _operation_key(change):
198
+ target = (["new-caption", change.language, change.name] if change.action == "caption-insert"
199
+ else ["caption", change.track_id] if change.track_id else ["thumbnail"])
200
+ return json.dumps([change.target_account, change.video_id, *target], ensure_ascii=False)
201
+
202
+
203
+ def prepare_resource(client, store, *, action, video_id, file=None, track_id=None,
204
+ language=None, name=None, draft=None):
205
+ if action not in {"thumbnail-set", "caption-insert", "caption-update", "caption-delete"}:
206
+ raise ResourceError("unsupported resource action")
207
+ resource_id(video_id)
208
+ if draft is not None and type(draft) is not bool:
209
+ raise ResourceError("draft must be explicit boolean")
210
+ if action == "caption-insert":
211
+ if track_id is not None or file is None or draft is None or not isinstance(language, str) or not re.fullmatch(r"[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*", language) or not isinstance(name, str) or len(name) > 150:
212
+ raise ResourceError("insert requires file, language, name (maximum 150) and explicit draft; no track ID")
213
+ elif action == "caption-update":
214
+ if track_id is None or language is not None or name is not None or (file is None and draft is None):
215
+ raise ResourceError("update requires track ID and file and/or draft; language/name cannot be edited")
216
+ elif action == "caption-delete":
217
+ if track_id is None or any(v is not None for v in (file, language, name, draft)):
218
+ raise ResourceError("delete requires only explicit track ID")
219
+ elif file is None or any(v is not None for v in (track_id, language, name, draft)):
220
+ raise ResourceError("thumbnail-set requires only a supplied file")
221
+ if track_id is not None:
222
+ resource_id(track_id)
223
+ asset = inspect_asset(file, thumbnail=action == "thumbnail-set")[0] if file is not None else None
224
+ inspected = client.inspect(video_id)
225
+ cid = str(uuid.uuid4())
226
+ draft_changed = draft is not None
227
+ backup = {"fidelity": "not_applicable"}
228
+ if action == "thumbnail-set":
229
+ before = {"etag": inspected.etag, "thumbnails": inspected.snippet.get("thumbnails", {}),
230
+ "video_snapshot": thumbnail_snapshot(inspected)}
231
+ backup = {"fidelity": "metadata_only_no_original_bytes",
232
+ "limitation": "API does not expose original thumbnail; public URLs do not guarantee original bytes or faithful restoration."}
233
+ elif action == "caption-insert":
234
+ tracks = client.list_captions(video_id)
235
+ if any(t["snippet"]["language"] == language and t["snippet"]["name"] == name for t in tracks):
236
+ raise ResourceError("conflict: caption with this language and name already exists; no implicit deletion or replacement")
237
+ before = {"etag": inspected.etag, "track_ids": [t["id"] for t in tracks]}
238
+ else:
239
+ before = client.find_caption(video_id, track_id)
240
+ language, name = before["snippet"]["language"], before["snippet"]["name"]
241
+ draft = before["snippet"]["isDraft"] if draft is None else draft
242
+ try:
243
+ original = client.download_caption(video_id, track_id)
244
+ except ResourceError:
245
+ backup = {"fidelity": "unavailable",
246
+ "limitation": "Failed to download original; permissions/format may prevent recovery. Restoration is not guaranteed."}
247
+ else:
248
+ store.save_backup(cid, original)
249
+ backup = {"fidelity": "api_original_response_bytes", "size": len(original),
250
+ "sha256": digest(original), "track_id": track_id,
251
+ "limitation": "Bytes received without tfmt/tlang. YouTube may normalize them; equality with originally uploaded file is unproven."}
252
+ moment = now_utc()
253
+ change = ResourceChange(id=cid, action=action, target_brand=client.brand.nombre,
254
+ target_account=inspected.channel_id, video_id=video_id, track_id=track_id,
255
+ language=language, name=name, draft=draft, draft_changed=draft_changed,
256
+ asset=asset, before=before, backup=backup, created_at=moment, updated_at=moment)
257
+ _event(change, "prepared")
258
+ change.fingerprint = fingerprint(change)
259
+ store.save(change)
260
+ return change
261
+
262
+
263
+ def _preflight(client, change):
264
+ video = _identity(client, change)
265
+ if change.action == "thumbnail-set":
266
+ validate_thumbnail_snapshot(change, video)
267
+ elif change.action == "caption-insert":
268
+ rows = client.list_captions(change.video_id)
269
+ if any(t["snippet"]["language"] == change.language and t["snippet"]["name"] == change.name for t in rows):
270
+ raise ResourceConflict("conflict: caption with this language/name already exists")
271
+ else:
272
+ if client.find_caption(change.video_id, change.track_id) != change.before:
273
+ raise ResourceConflict("conflict: caption changed since preview")
274
+ if change.backup["fidelity"] == "api_original_response_bytes":
275
+ if digest(client.download_caption(change.video_id, change.track_id)) != change.backup["sha256"]:
276
+ raise ResourceConflict("conflict: remote caption bytes changed")
277
+
278
+ return video
279
+
280
+
281
+ def _readback(client, store, change):
282
+ """Read-only; never infer insert identity or deletion from missing permissions."""
283
+ try:
284
+ video = _identity(client, change)
285
+ if change.action == "thumbnail-set":
286
+ _event(change, "thumbnail_readback", thumbnails=video.snippet.get("thumbnails", {}),
287
+ fidelity="metadata_only_not_visual_or_byte_verification")
288
+ if change.receipt is not None:
289
+ change.status = "accepted_unverifiable"
290
+ elif change.action == "caption-delete":
291
+ rows = client.list_captions(change.video_id)
292
+ absent = not any(t["id"] == change.track_id for t in rows)
293
+ _event(change, "delete_readback", absent_in_list=absent,
294
+ confirmed_delete=bool(change.receipt and change.receipt.get("http_status") == 204))
295
+ if absent and change.receipt and change.receipt.get("http_status") == 204:
296
+ change.status, change.verified = "verified", True
297
+ elif change.result_id and change.receipt and change.receipt.get("identity_valid"):
298
+ row = client.find_caption(change.video_id, change.result_id)
299
+ snip = row["snippet"]
300
+ matches = all(snip.get(k) == v for k, v in {"language": change.language, "name": change.name, "isDraft": change.draft}.items())
301
+ if snip.get("status") == "failed":
302
+ _event(change, "caption_processing_failed")
303
+ change.status = "failed"
304
+ else:
305
+ byte_match = (digest(client.download_caption(change.video_id, change.result_id)) == change.asset["sha256"]
306
+ if change.asset else True)
307
+ _event(change, "caption_readback", metadata_match=matches,
308
+ original_format_bytes_match=byte_match, processing_status=snip.get("status"))
309
+ if matches and byte_match and snip.get("status") == "serving":
310
+ change.status, change.verified = "verified", True
311
+ else:
312
+ rows = client.list_captions(change.video_id)
313
+ candidates = [t["id"] for t in rows if t["snippet"]["language"] == change.language and t["snippet"]["name"] == change.name]
314
+ _event(change, "identity_unresolved", candidate_ids=candidates,
315
+ limitation="Candidates do not prove which insertion produced resource; it will not be reinserted.")
316
+ except ResourceError as exc:
317
+ _event(change, "readback_unavailable", error=str(exc))
318
+ store.save(change)
319
+ return change
320
+
321
+
322
+ def reconcile_resource(client, store, change_id):
323
+ with store.apply_lock(change_id):
324
+ change = store.load(change_id)
325
+ _identity(client, change)
326
+ if change.status in {"applying", "uncertain", "accepted_unverifiable"}:
327
+ if change.status == "applying":
328
+ change.status = "uncertain"
329
+ return _readback(client, store, change)
330
+ return change
331
+
332
+
333
+ def apply_resource(client, store, change_id, approval_digest):
334
+ with ExitStack() as locks:
335
+ locks.enter_context(store.apply_lock(change_id))
336
+ change = store.load(change_id)
337
+ if not hmac.compare_digest(approval_digest, change.fingerprint):
338
+ raise ApprovalMismatch("approval does not match exact fingerprint")
339
+ # Serialize separate proposals for the same resource, not just retries of
340
+ # one UUID. A new UUID is not an escape hatch from an uncertain insertion.
341
+ key = _operation_key(change)
342
+ locks.enter_context(store.apply_lock(str(uuid.uuid5(uuid.NAMESPACE_URL, key))))
343
+ _identity(client, change)
344
+ if change.status in {"applying", "uncertain", "accepted_unverifiable"}:
345
+ if change.status == "applying":
346
+ change.status = "uncertain"
347
+ return _readback(client, store, change)
348
+ if change.status != "proposed":
349
+ return change
350
+ for path in store.root.glob("*.json"):
351
+ other = store.load(path.stem)
352
+ if other.id != change.id and _operation_key(other) == key and other.status in {"applying", "uncertain"}:
353
+ raise ResourceError(f"uncertain outcome pending in {other.id}; write will not be repeated with another proposal")
354
+ try:
355
+ validated_video = _preflight(client, change)
356
+ except ResourceConflict:
357
+ change.status = "conflict"
358
+ _event(change, "pre_write_conflict")
359
+ store.save(change)
360
+ raise
361
+ if change.backup["fidelity"] == "api_original_response_bytes":
362
+ backup_path = store.backup_path(change.id)
363
+ if backup_path.is_symlink() or not backup_path.is_file() or digest(backup_path.read_bytes()) != change.backup["sha256"]:
364
+ raise ResourceError("local caption backup changed or is missing; new proposal required")
365
+ data = None
366
+ if change.asset:
367
+ asset, data = inspect_asset(Path(change.asset["path"]), thumbnail=change.action == "thumbnail-set")
368
+ if asset != change.asset:
369
+ raise ResourceError("file or bytes changed since preview; new fingerprint required")
370
+ # The in-memory bytes checked after ownership reads are the bytes sent,
371
+ # even if the source pathname changes afterward. No reopen at upload time.
372
+ change.status = "applying"
373
+ _event(change, "write_intent", approval_digest=approval_digest)
374
+ store.save(change)
375
+ try:
376
+ response = (client.mutate(change, data, validated_video=validated_video)
377
+ if change.action == "thumbnail-set" else client.mutate(change, data))
378
+ except ResourceRejected as exc:
379
+ change.status = "conflict" if isinstance(exc, ResourceConflict) else "failed"
380
+ _event(change, "write_rejected", error=str(exc))
381
+ except ResourceUncertain as exc:
382
+ change.status = "uncertain"
383
+ _event(change, "write_uncertain", error=str(exc))
384
+ else:
385
+ change.status = "uncertain"
386
+ # Persist any valid response ID before subsequent validation/read-back.
387
+ if change.action in {"caption-insert", "caption-update"}:
388
+ try:
389
+ change.result_id = resource_id(response.get("id"))
390
+ except ResourceError:
391
+ change.result_id = None
392
+ snippet = response.get("snippet", {})
393
+ identity_valid = bool(change.result_id) and (change.action == "caption-insert" or change.result_id == change.track_id)
394
+ if change.action == "caption-insert":
395
+ identity_valid = identity_valid and isinstance(snippet, dict) and all(
396
+ snippet.get(k) == v for k, v in {"videoId": change.video_id, "language": change.language,
397
+ "name": change.name, "isDraft": change.draft}.items())
398
+ change.receipt = {"id": change.result_id, "identity_valid": identity_valid}
399
+ elif change.action == "caption-delete":
400
+ change.receipt = response
401
+ elif isinstance(response.get("items"), list) and response["items"] and isinstance(response.get("etag"), str):
402
+ change.receipt = {"etag": response["etag"], "accepted": True}
403
+ _event(change, "write_response", result_id=change.result_id, receipt=change.receipt)
404
+ store.save(change)
405
+ if change.status == "uncertain":
406
+ return _readback(client, store, change)
407
+ return change
@@ -0,0 +1,136 @@
1
+ """Supplied YouTube thumbnail/caption commands, exact durable approval only."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+
7
+ import httpx
8
+ import typer
9
+
10
+ from socialctl.approval_cli import read_approval
11
+
12
+ from socialctl.management.changes import ChangeError
13
+ from socialctl.management.resource_changes import (
14
+ ResourceStore, apply_resource, digest, prepare_resource, reconcile_resource, save_download,
15
+ )
16
+ from socialctl.management.youtube_resources import YouTubeResourcesClient
17
+
18
+
19
+ def make_http_client():
20
+ return httpx.Client(timeout=60.0, follow_redirects=False)
21
+
22
+
23
+ def _json(value):
24
+ return json.dumps(value, indent=2, ensure_ascii=False, sort_keys=True)
25
+
26
+
27
+ def render_resource_preview(change):
28
+ return ("DRY-RUN — FULL PREVIEW of supplied resource\n"
29
+ + _json(change.model_dump(mode="json"))
30
+ + "\nExact asset.sha256 bytes will be sent without generation, synchronization or conversion."
31
+ "\nThumbnail: response and URLs do not prove visual replacement or restoration of original bytes."
32
+ "\nCaptions: supplied UTF-8 SRT/VTT/SBV/SUB/MPSUB/LRC/SMI/SAMI/RT/TTML/DFXP/SCC: basic validation; "
33
+ "CAP/TDS/CIN/STL/ASC: extension/size/fingerprint only, provider validation pending."
34
+ "\nBackup: preserves original API response bytes, which may have been normalized."
35
+ "\ncaption-delete removes only specified track ID; it is not recreated automatically."
36
+ "\nUncertain outcomes never repeat writes. Effective permissions unknown until operation."
37
+ f"\nExact fingerprint for approval: {change.fingerprint}")
38
+
39
+
40
+ def register(content_app, default_root):
41
+ from socialctl.management.cli import _brand, _fail
42
+
43
+ assets = typer.Typer(help="Supplied thumbnails and captions for owned videos; durable approval.")
44
+ content_app.add_typer(assets, name="youtube-assets")
45
+
46
+ @assets.command("captions-list")
47
+ def captions_list(video_id: str, brand: str = typer.Option(..., "--brand"),
48
+ root: Path = typer.Option(default_root, "--root")):
49
+ selected = _brand(root, brand)
50
+ try:
51
+ with make_http_client() as http:
52
+ rows = YouTubeResourcesClient(selected, http).list_captions(video_id)
53
+ except (ChangeError, OSError, ValueError) as exc:
54
+ _fail(str(exc))
55
+ typer.echo(_json({"items": rows, "absence_proven": False,
56
+ "limitation": "captions.list does not document pagination; current permissions do not prove write permission."}))
57
+
58
+ @assets.command("captions-download")
59
+ def captions_download(video_id: str, track_id: str,
60
+ output: Path = typer.Option(..., "--output"),
61
+ brand: str = typer.Option(..., "--brand"),
62
+ root: Path = typer.Option(default_root, "--root")):
63
+ selected = _brand(root, brand)
64
+ try:
65
+ with make_http_client() as http:
66
+ data = YouTubeResourcesClient(selected, http).download_caption(video_id, track_id)
67
+ save_download(output, data)
68
+ except (ChangeError, OSError, ValueError) as exc:
69
+ _fail(str(exc))
70
+ typer.echo(_json({"path": str(output.absolute()), "sha256": digest(data), "size": len(data),
71
+ "video_id": video_id, "track_id": track_id, "fidelity": "api_original_response_bytes",
72
+ "limitation": "Without tfmt/tlang. Provider may have normalized original; files are not overwritten."}))
73
+
74
+ @assets.command("prepare")
75
+ def prepare(video_id: str, action: str = typer.Option(..., "--action", help="thumbnail-set, caption-insert, caption-update or caption-delete"),
76
+ file: Path | None = typer.Option(None, "--file"),
77
+ track_id: str | None = typer.Option(None, "--track-id"),
78
+ language: str | None = typer.Option(None, "--language"),
79
+ name: str | None = typer.Option(None, "--name"),
80
+ draft: bool | None = typer.Option(None, "--draft/--no-draft", help="Required for insert; omission on update preserves value."),
81
+ brand: str = typer.Option(..., "--brand"),
82
+ root: Path = typer.Option(default_root, "--root"),
83
+ dry_run: bool = typer.Option(False, "--dry-run")):
84
+ """Prepare and display complete dry-run without remote writes."""
85
+ selected = _brand(root, brand)
86
+ try:
87
+ with make_http_client() as http:
88
+ change = prepare_resource(YouTubeResourcesClient(selected, http), ResourceStore(selected.raiz),
89
+ action=action, video_id=video_id, file=file, track_id=track_id,
90
+ language=language, name=name, draft=draft)
91
+ except (ChangeError, OSError, ValueError) as exc:
92
+ _fail(str(exc))
93
+ typer.echo(render_resource_preview(change))
94
+
95
+ @assets.command("status")
96
+ def status(change_id: str, brand: str = typer.Option(..., "--brand"),
97
+ root: Path = typer.Option(default_root, "--root")):
98
+ selected = _brand(root, brand)
99
+ try:
100
+ change = ResourceStore(selected.raiz).load(change_id)
101
+ except ChangeError as exc:
102
+ _fail(str(exc))
103
+ typer.echo(_json(change.model_dump(mode="json")))
104
+
105
+ @assets.command("apply")
106
+ def apply(change_id: str, brand: str = typer.Option(..., "--brand"),
107
+ root: Path = typer.Option(default_root, "--root"),
108
+ approval_fingerprint: str | None = typer.Option(None, "--approval-fingerprint", "--approval", "--approval-digest", help="Exact fingerprint from the approved complete preview."),
109
+ approval_stdin: bool = typer.Option(False, "--approval-stdin", help="Explicitly read one approval line from stdin.")):
110
+ selected = _brand(root, brand)
111
+ store = ResourceStore(selected.raiz)
112
+ try:
113
+ change = store.load(change_id)
114
+ typer.echo(render_resource_preview(change))
115
+ approval = read_approval(approval_fingerprint, approval_stdin=approval_stdin)
116
+ if approval != change.fingerprint:
117
+ _fail("approval does not match exact fingerprint")
118
+ with make_http_client() as http:
119
+ result = apply_resource(YouTubeResourcesClient(selected, http), store, change_id, approval)
120
+ except (ChangeError, OSError, ValueError) as exc:
121
+ _fail(str(exc))
122
+ typer.echo(_json(result.model_dump(mode="json")))
123
+ if result.status != "verified":
124
+ raise typer.Exit(1)
125
+
126
+ @assets.command("reconcile")
127
+ def reconcile(change_id: str, brand: str = typer.Option(..., "--brand"),
128
+ root: Path = typer.Option(default_root, "--root")):
129
+ """Remote rereads only; never resend upload, update or delete."""
130
+ selected = _brand(root, brand)
131
+ try:
132
+ with make_http_client() as http:
133
+ result = reconcile_resource(YouTubeResourcesClient(selected, http), ResourceStore(selected.raiz), change_id)
134
+ except (ChangeError, OSError, ValueError) as exc:
135
+ _fail(str(exc))
136
+ typer.echo(_json(result.model_dump(mode="json")))
@@ -0,0 +1,26 @@
1
+ """Conservative snippet equality for post-write observation only."""
2
+ from collections import Counter
3
+
4
+
5
+ def snippet_mismatch_fields(current, expected):
6
+ """Keep exact fields, except omitted clears and exact keyword multisets."""
7
+ if not isinstance(current, dict) or not isinstance(expected, dict):
8
+ return ['snippet']
9
+ actual, wanted = dict(current), dict(expected)
10
+ for field, empty in (("tags", []), ("description", "")):
11
+ actual.setdefault(field, empty)
12
+ wanted.setdefault(field, empty)
13
+ left, right = actual['tags'], wanted['tags']
14
+ valid_tags = (isinstance(left, list) and isinstance(right, list)
15
+ and all(isinstance(tag, str) for tag in [*left, *right]))
16
+ if valid_tags:
17
+ actual['tags'], wanted['tags'] = Counter(left), Counter(right)
18
+ return [field for field in sorted(set(actual) | set(wanted))
19
+ if (field == 'tags' and not valid_tags)
20
+ or (field == 'description' and not all(isinstance(v, str) for v in (actual[field], wanted[field])))
21
+ or field not in actual
22
+ or field not in wanted or actual[field] != wanted[field]]
23
+
24
+
25
+ def same_snippet_readback(current, expected):
26
+ return not snippet_mismatch_fields(current, expected)