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.
- socialcli-0.3.0.dist-info/METADATA +168 -0
- socialcli-0.3.0.dist-info/RECORD +224 -0
- socialcli-0.3.0.dist-info/WHEEL +4 -0
- socialcli-0.3.0.dist-info/entry_points.txt +3 -0
- socialcli-0.3.0.dist-info/licenses/LICENSE +21 -0
- socialctl/__init__.py +0 -0
- socialctl/__main__.py +6 -0
- socialctl/adapters/__init__.py +6 -0
- socialctl/adapters/base.py +68 -0
- socialctl/adapters/errores.py +56 -0
- socialctl/adapters/facebook.py +180 -0
- socialctl/adapters/instagram.py +442 -0
- socialctl/adapters/tiktok.py +535 -0
- socialctl/adapters/youtube.py +331 -0
- socialctl/agent_errors.py +176 -0
- socialctl/agent_guide.py +23 -0
- socialctl/approval_cli.py +27 -0
- socialctl/auth.py +139 -0
- socialctl/authflow.py +344 -0
- socialctl/brands.py +194 -0
- socialctl/capabilities.py +57 -0
- socialctl/capabilities.yml +5945 -0
- socialctl/cli.py +1766 -0
- socialctl/connection_service/__init__.py +0 -0
- socialctl/connection_service/__main__.py +33 -0
- socialctl/connection_service/app.py +496 -0
- socialctl/connection_service/assets/PLATFORM-LOGOS-LICENSE.txt +50 -0
- socialctl/connection_service/assets/connection.css +75 -0
- socialctl/connection_service/assets/facebook.webp +0 -0
- socialctl/connection_service/assets/instagram.svg +194 -0
- socialctl/connection_service/assets/socialcli-logo.png +0 -0
- socialctl/connection_service/assets/tiktok.png +0 -0
- socialctl/connection_service/assets/youtube.svg +1 -0
- socialctl/connection_service/landing.py +146 -0
- socialctl/connection_service/legacy.py +48 -0
- socialctl/connection_service/peer.py +13 -0
- socialctl/connection_service/providers.py +324 -0
- socialctl/connection_service/settings.py +76 -0
- socialctl/connection_service/store.py +124 -0
- socialctl/connections/__init__.py +0 -0
- socialctl/connections/account_cli.py +438 -0
- socialctl/connections/cli.py +379 -0
- socialctl/connections/client.py +126 -0
- socialctl/connections/credential_cli.py +224 -0
- socialctl/connections/credential_store.py +49 -0
- socialctl/connections/file_store.py +193 -0
- socialctl/connections/keychain.py +78 -0
- socialctl/connections/meta_user.py +81 -0
- socialctl/connections/rotation_cli.py +116 -0
- socialctl/connections/saas_client.py +91 -0
- socialctl/connections/temporary_media.py +62 -0
- socialctl/delivery_options.py +70 -0
- socialctl/engagement.py +200 -0
- socialctl/engagement_cli.py +50 -0
- socialctl/executor.py +135 -0
- socialctl/formatter.py +291 -0
- socialctl/hosted_media.py +152 -0
- socialctl/identity.py +200 -0
- socialctl/inbox.py +298 -0
- socialctl/inbox_cli.py +46 -0
- socialctl/inventory.py +155 -0
- socialctl/inventory_cli.py +171 -0
- socialctl/inventory_sources.py +101 -0
- socialctl/management/__init__.py +1 -0
- socialctl/management/batch_cli.py +115 -0
- socialctl/management/batches.py +325 -0
- socialctl/management/changes.py +485 -0
- socialctl/management/cli.py +227 -0
- socialctl/management/comments_cli.py +352 -0
- socialctl/management/community_batch_cli.py +63 -0
- socialctl/management/community_batches.py +294 -0
- socialctl/management/community_changes.py +370 -0
- socialctl/management/community_cli.py +142 -0
- socialctl/management/community_schema.py +119 -0
- socialctl/management/facebook_groups.py +1118 -0
- socialctl/management/groups_cli.py +274 -0
- socialctl/management/inbox_readers.py +87 -0
- socialctl/management/meta_changes.py +197 -0
- socialctl/management/meta_cli.py +342 -0
- socialctl/management/meta_client.py +631 -0
- socialctl/management/meta_comment_inbox.py +565 -0
- socialctl/management/meta_comments.py +587 -0
- socialctl/management/meta_errors.py +59 -0
- socialctl/management/meta_pagination.py +52 -0
- socialctl/management/meta_schema.py +128 -0
- socialctl/management/meta_webhooks.py +147 -0
- socialctl/management/metadata_parts.py +144 -0
- socialctl/management/owned_assets.py +85 -0
- socialctl/management/owned_changes.py +470 -0
- socialctl/management/owned_cli.py +128 -0
- socialctl/management/owned_reorder.py +62 -0
- socialctl/management/owned_schema.py +368 -0
- socialctl/management/reply_context.py +24 -0
- socialctl/management/reply_evidence.py +80 -0
- socialctl/management/resource_changes.py +407 -0
- socialctl/management/resource_cli.py +136 -0
- socialctl/management/snippet_readback.py +26 -0
- socialctl/management/stories.py +713 -0
- socialctl/management/stories_cli.py +172 -0
- socialctl/management/youtube.py +323 -0
- socialctl/management/youtube_community.py +326 -0
- socialctl/management/youtube_metadata.py +122 -0
- socialctl/management/youtube_owned.py +259 -0
- socialctl/management/youtube_resources.py +305 -0
- socialctl/media.py +269 -0
- socialctl/media_cli.py +183 -0
- socialctl/media_ingress.py +211 -0
- socialctl/media_preflight.py +114 -0
- socialctl/media_registry.py +301 -0
- socialctl/media_requirements.py +52 -0
- socialctl/metricas/__init__.py +6 -0
- socialctl/metricas/almacen.py +133 -0
- socialctl/metricas/analytics_changes.py +295 -0
- socialctl/metricas/analytics_cli.py +191 -0
- socialctl/metricas/analytics_client.py +304 -0
- socialctl/metricas/analytics_ingest.py +260 -0
- socialctl/metricas/analytics_schema.py +174 -0
- socialctl/metricas/base.py +147 -0
- socialctl/metricas/facebook.py +418 -0
- socialctl/metricas/insights.py +227 -0
- socialctl/metricas/instagram.py +133 -0
- socialctl/metricas/modelos.py +87 -0
- socialctl/metricas/piezas.py +134 -0
- socialctl/metricas/reach.py +94 -0
- socialctl/metricas/reporting_client.py +112 -0
- socialctl/metricas/resumen.py +251 -0
- socialctl/metricas/slugs.py +89 -0
- socialctl/metricas/tiktok.py +185 -0
- socialctl/metricas/youtube.py +403 -0
- socialctl/migration_cli.py +60 -0
- socialctl/migration_files.py +68 -0
- socialctl/models.py +150 -0
- socialctl/native_schedule/__init__.py +13 -0
- socialctl/native_schedule/api_delivery.py +15 -0
- socialctl/native_schedule/api_reconcile.py +40 -0
- socialctl/native_schedule/approval.py +160 -0
- socialctl/native_schedule/cadence.py +73 -0
- socialctl/native_schedule/cli.py +510 -0
- socialctl/native_schedule/dispatcher.py +120 -0
- socialctl/native_schedule/facebook.py +112 -0
- socialctl/native_schedule/facebook_reels.py +140 -0
- socialctl/native_schedule/first_comment.py +181 -0
- socialctl/native_schedule/handoff.py +193 -0
- socialctl/native_schedule/instagram_delivery.py +224 -0
- socialctl/native_schedule/instagram_runtime.py +66 -0
- socialctl/native_schedule/inventory.py +70 -0
- socialctl/native_schedule/migration.py +310 -0
- socialctl/native_schedule/models.py +103 -0
- socialctl/native_schedule/reconcile.py +83 -0
- socialctl/native_schedule/remote_data.py +177 -0
- socialctl/native_schedule/report.py +151 -0
- socialctl/native_schedule/store.py +560 -0
- socialctl/native_schedule/verification.py +32 -0
- socialctl/native_schedule/youtube.py +339 -0
- socialctl/operation_output.py +87 -0
- socialctl/plantillas/accounts.yml +33 -0
- socialctl/plantillas/brand.md +95 -0
- socialctl/plantillas/estrategia.md +64 -0
- socialctl/postfile.py +240 -0
- socialctl/preflight.py +73 -0
- socialctl/publication_policy.py +34 -0
- socialctl/publication_status.py +75 -0
- socialctl/publication_steps.py +398 -0
- socialctl/publish_output.py +96 -0
- socialctl/publisher.py +445 -0
- socialctl/queue_migration.py +302 -0
- socialctl/read_budget.py +224 -0
- socialctl/read_reports.py +91 -0
- socialctl/rutas.py +103 -0
- socialctl/saas/__init__.py +1 -0
- socialctl/saas/access.py +75 -0
- socialctl/saas/auth.py +213 -0
- socialctl/saas/backup.py +124 -0
- socialctl/saas/billing.py +327 -0
- socialctl/saas/billing_enrollment.py +1237 -0
- socialctl/saas/billing_enrollment_schema.py +519 -0
- socialctl/saas/billing_inventory.py +775 -0
- socialctl/saas/billing_reconcile.py +127 -0
- socialctl/saas/billing_routes.py +27 -0
- socialctl/saas/budget.py +66 -0
- socialctl/saas/connections.py +374 -0
- socialctl/saas/consent.py +52 -0
- socialctl/saas/lifecycle.py +341 -0
- socialctl/saas/mail.py +89 -0
- socialctl/saas/maintenance.py +68 -0
- socialctl/saas/meta_user.py +298 -0
- socialctl/saas/meta_webhooks.py +358 -0
- socialctl/saas/migration.py +107 -0
- socialctl/saas/models.py +8 -0
- socialctl/saas/operations.py +102 -0
- socialctl/saas/pages.py +126 -0
- socialctl/saas/pairing.py +158 -0
- socialctl/saas/permissions.py +57 -0
- socialctl/saas/reminders.py +128 -0
- socialctl/saas/renewal.py +74 -0
- socialctl/saas/restore_guard.py +229 -0
- socialctl/saas/revocation.py +97 -0
- socialctl/saas/rotation.py +114 -0
- socialctl/saas/routes.py +221 -0
- socialctl/saas/settings.py +34 -0
- socialctl/saas/store.py +106 -0
- socialctl/saas/temporary_media.py +245 -0
- socialctl/saas/webhooks.py +76 -0
- socialctl/schedule_remote.py +262 -0
- socialctl/schedule_sqlite.py +44 -0
- socialctl/scheduler.py +581 -0
- socialctl/scheduler_launchd.py +319 -0
- socialctl/scheduler_systemd.py +167 -0
- socialctl/source_links.py +119 -0
- socialctl/ui/__init__.py +1 -0
- socialctl/ui/assets/agent-manual.md +850 -0
- socialctl/ui/assets/agents.md +27 -0
- socialctl/ui/assets/components.css +20 -0
- socialctl/ui/assets/connection-layout.css +53 -0
- socialctl/ui/assets/otp.template.html +10 -0
- socialctl/ui/assets/tokens.css +1 -0
- socialctl/ui/assets/trial.template.html +11 -0
- socialctl/ui/assets/web-layout.css +38 -0
- socialctl/ui/email/otp.html +10 -0
- socialctl/ui/email/trial.html +11 -0
- socialctl/upload_consent.py +13 -0
- socialctl/workspace.py +8 -0
- socialctl/youtube_data.py +471 -0
- 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
|