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,172 @@
1
+ """Safe, auditable CLI for preparing, applying and verifying Stories."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from datetime import datetime, timedelta, timezone
6
+ from pathlib import Path
7
+
8
+ import httpx
9
+ import typer
10
+ from socialctl.approval_cli import read_approval
11
+ from pydantic import ValidationError
12
+
13
+ from socialctl.management.changes import ChangeError
14
+ from socialctl.management.cli import DEFAULT_ROOT, _brand, _fail
15
+ from socialctl.management.stories import (
16
+ MetaStoryClient,
17
+ StoryError,
18
+ StoryStore,
19
+ apply_story,
20
+ prepare_story,
21
+ verify_story,
22
+ )
23
+ from socialctl.media import MediaInvalida, MediaNoEncontrada, leer_media
24
+ from socialctl.models import Platform, StoryPost
25
+ from socialctl.rutas import validar_ruta_relativa
26
+
27
+
28
+ story_app = typer.Typer(
29
+ help="Durable Instagram Stories and explicit handoff for Facebook Page Stories."
30
+ )
31
+
32
+
33
+ def make_http_client() -> httpx.Client:
34
+ return httpx.Client(timeout=30.0, follow_redirects=False)
35
+
36
+
37
+ def _json(value) -> str:
38
+ if hasattr(value, "model_dump"):
39
+ value = value.model_dump(mode="json")
40
+ return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True)
41
+
42
+
43
+ def render_story_preview(change) -> str:
44
+ delivery = (
45
+ "Instagram: media_type=STORIES will be sent; text is editorial reference and is not sent. "
46
+ "Public API is documented, but this account's write grant is unverified."
47
+ if change.story.platform is Platform.INSTAGRAM
48
+ else "Facebook: Page Stories has a public API, but socialcli does not implement its separate flow yet; apply returns handoff_required without POST."
49
+ )
50
+ return (
51
+ "FULL PREVIEW — standalone Story\n"
52
+ + _json(change)
53
+ + f"\n{delivery}"
54
+ + "\nLocal media is not modified, generated or reencoded."
55
+ + f"\nExact fingerprint for approval: {change.fingerprint}"
56
+ )
57
+
58
+
59
+ def _expiry(value: str | None) -> datetime:
60
+ if value is None:
61
+ return datetime.now(timezone.utc) + timedelta(hours=24)
62
+ try:
63
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
64
+ except (TypeError, ValueError):
65
+ raise StoryError("--expires-at must be an ISO 8601 timestamp with timezone") from None
66
+ if parsed.utcoffset() is None:
67
+ raise StoryError("--expires-at must include a timezone")
68
+ return parsed
69
+
70
+
71
+ @story_app.command("prepare")
72
+ def prepare(
73
+ platform: Platform = typer.Option(..., "--platform", help="instagram or facebook."),
74
+ media: str = typer.Option(..., "--media", help="Relative path inside <Brand>/media/."),
75
+ media_url: str | None = typer.Option(None, "--media-url", help="Exact public HTTPS URL; required for Instagram."),
76
+ text: str | None = typer.Option(None, "--text", help="Optional editorial text; Instagram does not receive it in F2."),
77
+ expires_at: str | None = typer.Option(None, "--expires-at", help="ISO 8601 expiry; default 24 hours."),
78
+ source_video_id: str | None = typer.Option(None, "--source-video-id", "--youtube-video-id", help="Optional related long-form YouTube video ID."),
79
+ brand: str = typer.Option(..., "--brand", help="Owning brand; never inferred."),
80
+ root: Path = typer.Option(DEFAULT_ROOT, "--root", help="Social project root."),
81
+ dry_run: bool = typer.Option(False, "--dry-run", help="Prepare and display local ChangeSet; never write to Meta."),
82
+ ) -> None:
83
+ """Validate and persist local proposal without any remote request."""
84
+ selected = _brand(root, brand)
85
+ try:
86
+ path = validar_ruta_relativa(
87
+ selected.raiz / "media", media, StoryError, "Story media"
88
+ )
89
+ asset = leer_media(path, ruta_relativa=media)
90
+ story = StoryPost(
91
+ platform=platform,
92
+ media=asset,
93
+ public_url=media_url,
94
+ text=text,
95
+ expires_at=_expiry(expires_at),
96
+ source_video_id=source_video_id,
97
+ )
98
+ change = prepare_story(selected, StoryStore(selected.raiz), story)
99
+ except (ChangeError, MediaNoEncontrada, MediaInvalida, ValidationError) as exc:
100
+ _fail(str(exc))
101
+ typer.echo("Remote DRY-RUN: local proposal persisted; no Meta writes.")
102
+ typer.echo(render_story_preview(change))
103
+
104
+
105
+ @story_app.command("status")
106
+ def status(
107
+ change_id: str,
108
+ brand: str = typer.Option(..., "--brand", help="Owning brand; never inferred."),
109
+ root: Path = typer.Option(DEFAULT_ROOT, "--root", help="Social project root."),
110
+ ) -> None:
111
+ """Display ChangeSet and journal without credentials or remote calls."""
112
+ selected = _brand(root, brand)
113
+ try:
114
+ change = StoryStore(selected.raiz).load(change_id)
115
+ except ChangeError as exc:
116
+ _fail(str(exc))
117
+ typer.echo(_json(change))
118
+
119
+
120
+ @story_app.command("apply")
121
+ def apply(
122
+ change_id: str,
123
+ approval_stdin: bool = typer.Option(False, "--approval-stdin", help="Explicitly read one approval line from stdin."),
124
+ approval_digest: str | None = typer.Option(None, "--approval-fingerprint", "--approval", "--approval-digest", help="Exact fingerprint displayed by prepare."),
125
+ brand: str = typer.Option(..., "--brand", help="Owning brand; never inferred."),
126
+ root: Path = typer.Option(DEFAULT_ROOT, "--root", help="Social project root."),
127
+ ) -> None:
128
+ """Require exact fingerprint and apply without repeating uncertain POSTs."""
129
+ selected = _brand(root, brand)
130
+ store = StoryStore(selected.raiz)
131
+ try:
132
+ prepared = store.load(change_id)
133
+ typer.echo(render_story_preview(prepared))
134
+ digest = read_approval(approval_digest, approval_stdin=approval_stdin)
135
+ if digest != prepared.fingerprint:
136
+ raise StoryError("approval does not match exact Story fingerprint")
137
+ if prepared.story.platform is Platform.FACEBOOK:
138
+ result = apply_story(None, store, change_id, digest, brand=selected)
139
+ else:
140
+ with make_http_client() as http:
141
+ client = MetaStoryClient(selected, Platform.INSTAGRAM, http)
142
+ result = apply_story(client, store, change_id, digest)
143
+ except (ChangeError, OSError, ValueError) as exc:
144
+ _fail(str(exc))
145
+ typer.echo(_json(result))
146
+ if result.status != "verified":
147
+ raise typer.Exit(1)
148
+
149
+
150
+ @story_app.command("verify")
151
+ def verify(
152
+ change_id: str,
153
+ brand: str = typer.Option(..., "--brand", help="Owning brand; never inferred."),
154
+ root: Path = typer.Option(DEFAULT_ROOT, "--root", help="Social project root."),
155
+ ) -> None:
156
+ """Use GET only to verify an already persisted remote ID; never publish."""
157
+ selected = _brand(root, brand)
158
+ store = StoryStore(selected.raiz)
159
+ try:
160
+ prepared = store.load(change_id)
161
+ if prepared.story.platform is Platform.FACEBOOK:
162
+ result = prepared
163
+ else:
164
+ with make_http_client() as http:
165
+ result = verify_story(
166
+ MetaStoryClient(selected, Platform.INSTAGRAM, http), store, change_id
167
+ )
168
+ except (ChangeError, OSError, ValueError) as exc:
169
+ _fail(str(exc))
170
+ typer.echo(_json(result))
171
+ if result.status != "verified":
172
+ raise typer.Exit(1)
@@ -0,0 +1,323 @@
1
+ """Safe YouTube metadata inspection and editing via standard Data API endpoints; V1 edits snippet, V2 validates other parts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from socialctl.agent_errors import with_error_code, with_http_error_code
12
+ from socialctl.adapters.errores import mensaje_de_error
13
+ from socialctl.auth import AuthError, obtener_token
14
+ from socialctl.brands import Brand
15
+ from socialctl.models import Platform
16
+
17
+ CHANNELS_URL = "https://www.googleapis.com/youtube/v3/channels"
18
+ VIDEOS_URL = "https://www.googleapis.com/youtube/v3/videos"
19
+
20
+ WRITABLE_SNIPPET_FIELDS = frozenset(
21
+ {
22
+ "title",
23
+ "description",
24
+ "tags",
25
+ "categoryId",
26
+ "defaultLanguage",
27
+ "defaultAudioLanguage",
28
+ }
29
+ )
30
+ READ_ONLY_SNIPPET_FIELDS = frozenset(
31
+ {
32
+ "publishedAt",
33
+ "channelId",
34
+ "thumbnails",
35
+ "channelTitle",
36
+ "liveBroadcastContent",
37
+ "localized",
38
+ }
39
+ )
40
+
41
+
42
+ class YouTubeManagementError(RuntimeError):
43
+ """Safe read or identity validation failure."""
44
+
45
+
46
+ class YouTubeUpdateRejected(YouTubeManagementError):
47
+ """YouTube unequivocally rejected the write."""
48
+
49
+ def __init__(self, message: str, *, diagnostic: dict[str, str] | None = None):
50
+ super().__init__(message)
51
+ self.diagnostic = diagnostic
52
+
53
+
54
+ class YouTubeUpdateConflict(YouTubeUpdateRejected):
55
+ """ETag changed between reread and PUT (HTTP 412)."""
56
+
57
+
58
+ class YouTubeUpdateUncertain(YouTubeManagementError):
59
+ """Whether PUT was applied cannot be determined reliably."""
60
+
61
+
62
+ # Only recognized reason identifiers may enter journals; provider prose is excluded.
63
+ _REJECTION_REASONS = frozenset({
64
+ "quotaExceeded", "dailyLimitExceeded", "rateLimitExceeded", "userRateLimitExceeded",
65
+ "insufficientPermissions", "forbidden", "forbiddenPrivacySetting",
66
+ "invalidTitle", "invalidDescription", "invalidVideoMetadata", "invalidValue",
67
+ "invalidPublishAt", "invalidCategoryId", "invalidTags", "invalidDefaultLanguage",
68
+ "defaultLanguageNotSet", "localizationValidationError", "videoNotFound",
69
+ "videoChartNotFound", "uploadLimitExceeded", "badRequest", "authError",
70
+ })
71
+
72
+
73
+ def _rejection_diagnostic(response: httpx.Response) -> dict[str, str]:
74
+ """Keep bounded classification, never provider prose, bodies, URLs or headers."""
75
+ status = str(response.status_code)
76
+ reasons = []
77
+ try:
78
+ errors = response.json()["error"]["errors"]
79
+ if isinstance(errors, list):
80
+ for item in errors[:16]:
81
+ reason = item.get("reason") if isinstance(item, dict) else None
82
+ if isinstance(reason, str) and reason in _REJECTION_REASONS and reason not in reasons:
83
+ reasons.append(reason)
84
+ except (ValueError, KeyError, TypeError):
85
+ pass
86
+ diagnostic = {"http_status": status}
87
+ if reasons:
88
+ diagnostic["reason"] = ",".join(reasons)
89
+ reason_text = f"; reason={diagnostic['reason']}" if reasons else ""
90
+ diagnostic["error"] = f"YouTube rejected videos.update (HTTP {status}{reason_text}); no change applied."
91
+ if "insufficientPermissions" in reasons:
92
+ diagnostic["error"] += " Check granted youtube.force-ssl or youtube management permission."
93
+ return diagnostic
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class InspectedVideo:
98
+ video_id: str
99
+ channel_id: str
100
+ etag: str
101
+ snippet: dict[str, Any]
102
+
103
+
104
+ class YouTubeManagementClient:
105
+ """Inspect a video and update only its complete snippet."""
106
+
107
+ def __init__(self, brand: Brand, client: httpx.Client) -> None:
108
+ self.brand = brand
109
+ self.client = client
110
+
111
+ @property
112
+ def configured_channel_id(self) -> str:
113
+ account = self.brand.cuentas.get("youtube") or {}
114
+ channel_id = account.get("channel_id") if isinstance(account, dict) else None
115
+ if not isinstance(channel_id, str) or not channel_id.strip():
116
+ raise YouTubeManagementError(
117
+ f"youtube.channel_id missing in {self.brand.raiz / 'accounts.yml'}"
118
+ )
119
+ return channel_id
120
+
121
+ def inspect(self, video_id: str) -> InspectedVideo:
122
+ """Read video after verifying configured account, token and ownership."""
123
+ if not isinstance(video_id, str) or not video_id.strip():
124
+ raise YouTubeManagementError("video ID is empty")
125
+
126
+ configured = self.configured_channel_id
127
+ token = self._token()
128
+ authenticated = self._authenticated_channel(token)
129
+ if authenticated != configured:
130
+ raise YouTubeManagementError(
131
+ "authenticated channel does not match brand youtube.channel_id "
132
+ f"{self.brand.nombre}: authenticated={authenticated}, configured={configured}"
133
+ )
134
+
135
+ payload = self._get_json(
136
+ VIDEOS_URL,
137
+ token,
138
+ params={"part": "snippet", "id": video_id},
139
+ operation="read video",
140
+ )
141
+ items = payload.get("items") if isinstance(payload, dict) else None
142
+ if not isinstance(items, list) or not items:
143
+ raise YouTubeManagementError(
144
+ f"YouTube did not return video {video_id}; check its ID and permissions"
145
+ )
146
+ item = items[0]
147
+ if not isinstance(item, dict) or item.get("id") != video_id:
148
+ raise YouTubeManagementError("YouTube returned an unexpected video resource")
149
+ snippet = item.get("snippet")
150
+ if not isinstance(snippet, dict):
151
+ raise YouTubeManagementError("YouTube returned an invalid video snippet")
152
+
153
+ channel_id = snippet.get("channelId")
154
+ if channel_id != configured:
155
+ raise YouTubeManagementError(
156
+ f"video {video_id} does not belong to the configured channel for "
157
+ f"{self.brand.nombre}"
158
+ )
159
+
160
+ unknown = set(snippet) - WRITABLE_SNIPPET_FIELDS - READ_ONLY_SNIPPET_FIELDS
161
+ if unknown:
162
+ names = ", ".join(sorted(unknown))
163
+ raise YouTubeManagementError(
164
+ "snippet contains an unknown field with uncertain mutability: "
165
+ f"{names}; editing rejected to avoid dropping it"
166
+ )
167
+
168
+ etag = item.get("etag")
169
+ if not isinstance(etag, str) or not etag:
170
+ raise YouTubeManagementError(
171
+ "YouTube did not return an ETag; editing rejected to avoid overwriting "
172
+ "a concurrent change"
173
+ )
174
+ return InspectedVideo(
175
+ video_id=video_id,
176
+ channel_id=channel_id,
177
+ etag=etag,
178
+ snippet=dict(snippet),
179
+ )
180
+
181
+ def update_snippet(
182
+ self, video_id: str, snippet: dict[str, Any], *, etag: str
183
+ ) -> None:
184
+ self._update_parts(video_id, {"snippet": snippet}, etag=etag)
185
+
186
+ def _update_parts(
187
+ self, video_id: str, parts: dict[str, Any], *, etag: str,
188
+ before_put: Callable[[], None] | None = None,
189
+ ) -> None:
190
+ """Send one conditional PUT for caller-validated parts. Transport/5xx errors are uncertain; 412 conflicts and other 4xx rejections are definitive."""
191
+ if not etag:
192
+ raise YouTubeUpdateRejected("cannot update without ETag")
193
+ configured = self.configured_channel_id
194
+ token = self._token()
195
+ authenticated = self._authenticated_channel(token)
196
+ if authenticated != configured:
197
+ raise YouTubeUpdateRejected(
198
+ "authenticated channel changed before videos.update; PUT was not sent"
199
+ )
200
+ headers = {
201
+ "Authorization": f"Bearer {token}",
202
+ "If-Match": etag,
203
+ "Content-Type": "application/json",
204
+ }
205
+ body = {"id": video_id, **parts}
206
+ # Time-sensitive outgoing metadata must be checked after token refresh
207
+ # and final authenticated-channel lookup, immediately before the PUT.
208
+ if before_put is not None:
209
+ before_put()
210
+ try:
211
+ response = self.client.put(
212
+ VIDEOS_URL,
213
+ headers=headers,
214
+ params={"part": ",".join(sorted(parts))},
215
+ json=body,
216
+ )
217
+ except httpx.TimeoutException:
218
+ raise YouTubeUpdateUncertain(
219
+ "uncertain outcome: videos.update timed out"
220
+ ) from None
221
+ except (httpx.HTTPError, httpx.InvalidURL):
222
+ raise YouTubeUpdateUncertain(
223
+ "uncertain outcome: connection lost during videos.update"
224
+ ) from None
225
+ except Exception:
226
+ raise YouTubeUpdateUncertain(
227
+ "uncertain outcome: unexpected failure during videos.update"
228
+ ) from None
229
+
230
+ error = mensaje_de_error(response, token)
231
+ if response.status_code == 412:
232
+ raise YouTubeUpdateConflict(
233
+ "ETag conflict (HTTP 412 conditionNotMet); PUT was not retried"
234
+ )
235
+ if 500 <= response.status_code:
236
+ raise YouTubeUpdateUncertain(
237
+ f"uncertain outcome after videos.update: {error}"
238
+ )
239
+ if not response.is_success:
240
+ diagnostic = _rejection_diagnostic(response)
241
+ raise YouTubeUpdateRejected(diagnostic["error"], diagnostic=diagnostic)
242
+
243
+ try:
244
+ payload = response.json()
245
+ except Exception:
246
+ raise YouTubeUpdateUncertain(
247
+ "uncertain outcome: videos.update returned unverifiable JSON"
248
+ ) from None
249
+ if not isinstance(payload, dict) or payload.get("id") != video_id:
250
+ raise YouTubeUpdateUncertain(
251
+ "uncertain outcome: videos.update returned an unexpected resource"
252
+ )
253
+
254
+ def editable_snippet(self, inspected: InspectedVideo) -> dict[str, Any]:
255
+ """Extract all present writable fields without inventing absent values."""
256
+ return {
257
+ key: inspected.snippet[key]
258
+ for key in WRITABLE_SNIPPET_FIELDS
259
+ if key in inspected.snippet
260
+ }
261
+
262
+ def _token(self) -> str:
263
+ try:
264
+ return obtener_token(self.brand, Platform.YOUTUBE, self.client)
265
+ except AuthError as exc:
266
+ raise with_error_code(YouTubeManagementError(str(exc)), exc, read_only=True) from None
267
+ except Exception as exc:
268
+ # Un fallo imprevisto podría arrastrar el secreto en su texto.
269
+ raise with_error_code(YouTubeManagementError("failed to read or refresh YouTube credentials"), exc, read_only=True, fallback="authorization_unavailable") from None
270
+
271
+ def _authenticated_channel(self, token: str) -> str:
272
+ payload = self._get_json(
273
+ CHANNELS_URL,
274
+ token,
275
+ params={"part": "id", "mine": "true"},
276
+ operation="check authenticated channel",
277
+ )
278
+ items = payload.get("items") if isinstance(payload, dict) else None
279
+ if not isinstance(items, list) or not items or not isinstance(items[0], dict):
280
+ raise YouTubeManagementError(
281
+ "Data API returned no channel for the brand token"
282
+ )
283
+ channel_id = items[0].get("id")
284
+ if not isinstance(channel_id, str) or not channel_id:
285
+ raise YouTubeManagementError("Data API returned an invalid authenticated channel")
286
+ return channel_id
287
+
288
+ def _get_json(
289
+ self, url: str, token: str, *, params: dict[str, str], operation: str
290
+ ) -> dict[str, Any]:
291
+ try:
292
+ response = self.client.get(
293
+ url, headers={"Authorization": f"Bearer {token}"}, params=params
294
+ )
295
+ except httpx.TimeoutException as exc:
296
+ raise with_error_code(YouTubeManagementError(
297
+ f"timed out while attempting to {operation} on YouTube"), exc, read_only=True) from None
298
+ except (httpx.HTTPError, httpx.InvalidURL) as exc:
299
+ raise with_error_code(YouTubeManagementError(
300
+ f"failed to connect to YouTube to {operation}"), exc, read_only=True) from None
301
+ except Exception:
302
+ raise YouTubeManagementError(
303
+ f"unexpected failure during {operation} on YouTube"
304
+ ) from None
305
+ if not response.is_success:
306
+ detail = mensaje_de_error(response, token)
307
+ if response.status_code == 403:
308
+ raise with_http_error_code(YouTubeManagementError(
309
+ f"YouTube did not allow {operation}; check youtube.readonly. "
310
+ f"Detail: {detail}"), response, read_only=True)
311
+ raise with_http_error_code(YouTubeManagementError(
312
+ f"YouTube returned {response.status_code} during {operation}: {detail}"), response, read_only=True)
313
+ try:
314
+ payload = response.json()
315
+ except Exception:
316
+ raise YouTubeManagementError(
317
+ f"YouTube returned a non-JSON response during {operation}"
318
+ ) from None
319
+ if not isinstance(payload, dict):
320
+ raise YouTubeManagementError(
321
+ f"YouTube returned an invalid response during {operation}"
322
+ )
323
+ return payload