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,471 @@
1
+ """Conservative local YouTube API data expiry, with replay-blocking tombstones.
2
+
3
+ Only enumerated application stores are managed. User files and Studio imports
4
+ are excluded. No process can erase files while its host is stopped.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import fcntl
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import re
13
+ import sqlite3
14
+ import time
15
+ from contextlib import contextmanager
16
+ from datetime import datetime, timedelta, timezone
17
+ from pathlib import Path
18
+
19
+ import yaml
20
+
21
+ from socialctl.migration_files import write_json
22
+ from socialctl.brands import AccountsInvalido
23
+
24
+
25
+ class YouTubeDataCleanupError(AccountsInvalido, ValueError):
26
+ """Managed provider data could not be removed; access remains blocked."""
27
+
28
+
29
+ class YouTubeDataBusy(YouTubeDataCleanupError):
30
+ """Another operation still owns a managed-store lock after a bounded wait."""
31
+
32
+
33
+ MAX_AGE_DAYS = 6
34
+ LOCK_WAIT_SECONDS = 2.0
35
+ JOURNALS = ('changes', 'metadata-batches', 'youtube-resources', 'youtube-owned',
36
+ 'youtube-community', 'community-batches', 'youtube-analytics-changes')
37
+
38
+
39
+ def expired(value, now):
40
+ """Unknown ages are not permission to keep API data indefinitely."""
41
+ try:
42
+ stamp = datetime.fromisoformat(value.replace('Z', '+00:00'))
43
+ if stamp.tzinfo is None:
44
+ stamp = stamp.replace(tzinfo=timezone.utc)
45
+ return stamp <= now - timedelta(days=MAX_AGE_DAYS)
46
+ except (AttributeError, TypeError, ValueError):
47
+ return True
48
+
49
+
50
+ def _age(data):
51
+ return data.get('created_at') or data.get('first_observed_at') or data.get('observed_at')
52
+
53
+
54
+ def _safe(path, root):
55
+ path.relative_to(root)
56
+ for part in (path, *path.parents):
57
+ if part.is_symlink():
58
+ raise ValueError('Symlink in managed data path; cleanup blocked')
59
+ if part == root:
60
+ break
61
+ return path
62
+
63
+
64
+ @contextmanager
65
+ def _lock(path):
66
+ path.parent.mkdir(parents=True, exist_ok=True)
67
+ fd = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
68
+ try:
69
+ deadline = time.monotonic() + LOCK_WAIT_SECONDS
70
+ while True:
71
+ try:
72
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
73
+ break
74
+ except BlockingIOError:
75
+ remaining = deadline - time.monotonic()
76
+ if remaining <= 0:
77
+ raise
78
+ time.sleep(min(0.05, remaining))
79
+ yield
80
+ finally:
81
+ os.close(fd)
82
+
83
+
84
+ def _tombstone(data):
85
+ # Supplied editable text/file paths survive, but no before/after/API receipt.
86
+ result = {'data_lifecycle':'purged', 'replay_blocked':True,
87
+ 'id':data.get('id'), 'platform':'youtube',
88
+ 'prior_state':data.get('status', data.get('state', 'unknown')),
89
+ 'fingerprint':data.get('fingerprint'),
90
+ 'user_input':{k:data[k] for k in ('edit','patch','file','slug','text') if k in data}}
91
+ if 'children' in data:
92
+ result['preserved_other_platforms']=[r for r in data['children'] if r.get('provider')!='youtube-community']
93
+ ids={r.get('change_id') for r in result['preserved_other_platforms']}
94
+ result['preserved_outcomes']={k:v for k,v in data.get('outcomes',{}).items() if k in ids}
95
+ return result
96
+
97
+
98
+
99
+ def _native_cleanup(path, *, now, purge):
100
+ """Use one write transaction; keep occupied slots while removing receipts."""
101
+ touched=[]
102
+ with sqlite3.connect(path, timeout=1) as db:
103
+ db.execute('PRAGMA secure_delete=ON')
104
+ db.execute('BEGIN IMMEDIATE')
105
+ tables={row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
106
+ if not tables:
107
+ return []
108
+ for ident,raw in db.execute("SELECT id,data FROM jobs WHERE platform='youtube'").fetchall():
109
+ data=json.loads(raw)
110
+ has_receipt=bool(data.get('remote_id'))
111
+ if 'attempts' in tables:
112
+ has_receipt=has_receipt or bool(db.execute('SELECT 1 FROM attempts WHERE job_id=?',(ident,)).fetchone())
113
+ if 'observations' in tables:
114
+ observations=db.execute('SELECT rowid,data FROM observations WHERE job_id=?',(ident,)).fetchall()
115
+ has_receipt=has_receipt or bool(observations)
116
+ for rowid,observation in observations:
117
+ if purge or expired(json.loads(observation).get('observed_at'),now):
118
+ db.execute('DELETE FROM observations WHERE rowid=?',(rowid,));touched.append(ident)
119
+ if 'attempts' in tables:
120
+ columns={row[1] for row in db.execute('PRAGMA table_info(attempts)')}
121
+ stamp_column='started_at' if 'started_at' in columns else 'NULL'
122
+ for rowid,stamp,checkpoint in db.execute('SELECT rowid,'+stamp_column+',checkpoint FROM attempts WHERE job_id=?',(ident,)).fetchall():
123
+ prior=json.loads(checkpoint)
124
+ if prior.get('data_lifecycle')!='purged' and (purge or expired(stamp or data.get('created_at'),now)):
125
+ db.execute('UPDATE attempts SET checkpoint=? WHERE rowid=?',(json.dumps({'data_lifecycle':'purged','replay_blocked':True}),rowid));touched.append(ident)
126
+ for table in ('actions','action_events'):
127
+ if table not in tables:continue
128
+ for rowid,raw_action in db.execute('SELECT rowid,data FROM '+table+' WHERE job_id=?',(ident,)).fetchall():
129
+ action=json.loads(raw_action)
130
+ if action.get('data_lifecycle')=='purged':continue
131
+ prior_job=json.loads(action['job']) if isinstance(action.get('job'),str) else {}
132
+ stamp=action.get('created_at') or action.get('submitted_at') or prior_job.get('updated_at')
133
+ if purge or expired(stamp,now):
134
+ if table=='action_events':db.execute('DELETE FROM action_events WHERE rowid=?',(rowid,))
135
+ else:db.execute('UPDATE actions SET data=? WHERE rowid=?',(json.dumps({'data_lifecycle':'purged','replay_blocked':True,'status':'expired'}),rowid))
136
+ touched.append(ident)
137
+ if 'notices' in tables:
138
+ for rowid,stamp in db.execute('SELECT rowid,created_at FROM notices WHERE job_id=?',(ident,)).fetchall():
139
+ if purge or expired(stamp,now):db.execute('DELETE FROM notices WHERE rowid=?',(rowid,));touched.append(ident)
140
+ if not purge and not has_receipt:continue # Supplied future intent is not an API cache.
141
+ if data.get('state')=='blocked' and data.get('route')=='blocked' and data.get('remote_id') is None:continue
142
+ if not (purge or expired(data.get('observation_watermark') or data.get('created_at'),now)):continue
143
+ data.update(state='blocked',route='blocked',approval_digest=None,remote_id=None,
144
+ observation_watermark=None,reconcile_after=None)
145
+ db.execute('UPDATE jobs SET data=? WHERE id=?',(json.dumps(data),ident))
146
+ if 'attempts' in tables:
147
+ db.execute('UPDATE attempts SET checkpoint=? WHERE job_id=?',(json.dumps({'data_lifecycle':'purged','replay_blocked':True}),ident))
148
+ for table in ('observations','actions','action_events','notices'):
149
+ if table in tables:db.execute('DELETE FROM '+table+' WHERE job_id=?',(ident,))
150
+ touched.append(ident)
151
+ if 'calendar_inventory' in tables:
152
+ for digest,raw in db.execute('SELECT digest,data FROM calendar_inventory').fetchall():
153
+ data=json.loads(raw)
154
+ if purge or expired(data.get('observed_at'),now):
155
+ old=json.dumps(data,sort_keys=True)
156
+ data['objects']=[r for r in data.get('objects',[]) if r.get('platform')!='youtube']
157
+ for account in data.get('accounts',[]):
158
+ if account.get('platform')=='youtube':account['complete']=False
159
+ if json.dumps(data,sort_keys=True)!=old:
160
+ db.execute('UPDATE calendar_inventory SET data=? WHERE digest=?',(json.dumps(data),digest));touched.append('calendar')
161
+ if 'external_reservations' in tables:
162
+ rows=db.execute("SELECT rowid,remote_ref FROM external_reservations WHERE platform='youtube' AND inventory_digest=?",(digest,)).fetchall()
163
+ for rowid,remote in rows:
164
+ if not remote.startswith('purged:'):
165
+ db.execute('UPDATE external_reservations SET remote_ref=? WHERE rowid=?',('purged:'+hashlib.sha256(remote.encode()).hexdigest(),rowid))
166
+ db.commit()
167
+ if touched:
168
+ db.execute('PRAGMA wal_checkpoint(TRUNCATE)')
169
+ db.execute('VACUUM')
170
+ return touched
171
+
172
+
173
+
174
+ def _legacy_rows(value, *, now, purge, inherited=False, parent_stamp=None):
175
+ """Retain supplied plans and other platforms, invalidate old YouTube receipts."""
176
+ if isinstance(value,list):
177
+ return [_legacy_rows(v,now=now,purge=purge,inherited=inherited,parent_stamp=parent_stamp) for v in value]
178
+ if not isinstance(value,dict):return value
179
+ platform=value.get('platform')
180
+ youtube=(platform=='youtube') if platform is not None else inherited
181
+ # An unsent schedule is user intent, not an API observation. Its age alone
182
+ # must never cancel approval, alter supplied content, or stop future delivery.
183
+ # Explicit disconnect/purge still blocks every YouTube execution path.
184
+ receipt_fields=('platform_id','remote_id','last_error','receipt','observed',
185
+ 'before','after','result','error','remote_ref')
186
+ has_receipt=(any(value.get(field) for field in receipt_fields)
187
+ or bool(value.get('attempts'))
188
+ or value.get('status',value.get('state')) in {
189
+ 'running','sending','uncertain','published','native_scheduled',
190
+ 'submitted','confirmed','failed'})
191
+ if (youtube and not purge and not has_receipt
192
+ and ('scheduled_at' in value or 'publish_at' in value)):
193
+ return value
194
+ inventory=value.get('inventory',{})
195
+ proposal=value.get('proposal',{})
196
+ previous_inventory=proposal.get('inventory',{}) if isinstance(proposal,dict) else {}
197
+ stamp=(_age(value) or (inventory.get('observed_at') if isinstance(inventory,dict) else None)
198
+ or (previous_inventory.get('observed_at') if isinstance(previous_inventory,dict) else None) or parent_stamp)
199
+ stale=purge or expired(stamp,now)
200
+ result={k:_legacy_rows(v,now=now,purge=purge,inherited=youtube,parent_stamp=stamp) for k,v in value.items()}
201
+ if youtube and stale:
202
+ for field in ('platform_id','remote_id','url','last_error','receipt','observed','before','after','result','error','remote_ref'):
203
+ if field in result:result[field]=None
204
+ if 'status' in result:result['status']='blocked'
205
+ if 'state' in result:result['state']='blocked'
206
+ for field in ('content_hash','approval_digest'):
207
+ if field in result:result[field]=None
208
+ return result
209
+
210
+
211
+ def _legacy_database(path, *, now, purge):
212
+ changed=False
213
+ with sqlite3.connect(path,timeout=1) as db:
214
+ db.execute('PRAGMA secure_delete=ON');db.execute('BEGIN IMMEDIATE')
215
+ for position,raw in db.execute('SELECT position,payload FROM entries').fetchall():
216
+ data=json.loads(raw);cleaned=_legacy_rows(data,now=now,purge=purge)
217
+ if cleaned!=data:
218
+ db.execute('UPDATE entries SET payload=? WHERE position=?',(json.dumps(cleaned),position));changed=True
219
+ db.commit()
220
+ if changed:db.execute('PRAGMA wal_checkpoint(TRUNCATE)');db.execute('VACUUM')
221
+ return changed
222
+
223
+
224
+ def cleanup_youtube_data(brand_root, *, now=None, purge=False):
225
+ """Expire managed API data for one explicit brand; purge ignores the age.
226
+
227
+ Failures are returned individually, never mistaken for successful deletion.
228
+ Uncertain journals become unreadable to execution paths, preserving a guard.
229
+ """
230
+ root = Path(brand_root).absolute()
231
+ now = now or datetime.now(timezone.utc)
232
+ marker = root/'.socialctl/youtube-purge-required.json'
233
+ result = {'version':1,'complete':True,'changed':[], 'errors':[], 'manual_review':[],
234
+ 'max_age_days':MAX_AGE_DAYS, 'purge':purge,
235
+ 'limits':'Runs only while this client/cleanup job runs. External exports, chats, user files and independent backups are outside this managed store.'}
236
+ def fail(path, exc):
237
+ result['complete'] = False
238
+ busy = isinstance(exc, BlockingIOError) or (
239
+ isinstance(exc, sqlite3.OperationalError) and
240
+ getattr(exc, 'sqlite_errorcode', None) in {sqlite3.SQLITE_BUSY, sqlite3.SQLITE_LOCKED})
241
+ result['errors'].append({'path':str(path.relative_to(root)), 'error':type(exc).__name__,
242
+ 'code':'local_data_busy' if busy else 'storage_failure'})
243
+ try:
244
+ _safe(marker,root)
245
+ except ValueError as exc:
246
+ fail(marker,exc)
247
+ return result
248
+ purge=purge or marker.exists()
249
+ result['purge']=purge
250
+ def changed(path):
251
+ result['changed'].append(str(path.relative_to(root)))
252
+ def read(path):
253
+ return json.loads(_safe(path, root).read_text())
254
+ def save(path, data):
255
+ write_json(_safe(path, root), data); changed(path)
256
+ def remove(path):
257
+ if _safe(path,root).exists(): path.unlink(); changed(path)
258
+ def files(directory, pattern):
259
+ try:
260
+ _safe(directory,root)
261
+ return list(directory.glob(pattern)) if directory.exists() else []
262
+ except (OSError,ValueError) as exc:
263
+ fail(directory,exc); return []
264
+ def run(path, operation):
265
+ try: operation()
266
+ except (OSError,ValueError,KeyError,TypeError,AttributeError,sqlite3.Error,yaml.YAMLError) as exc: fail(path,exc)
267
+
268
+ metrics = root/'metricas'
269
+ for path in files(metrics,'????-??-??.json'):
270
+ def snapshot():
271
+ data=read(path); row=data.get('redes',{}).get('youtube')
272
+ if row is not None and (purge or expired(row.get('observed_at') or data.get('fecha'),now)):
273
+ del data['redes']['youtube']; save(path,data)
274
+ run(path,snapshot)
275
+ path=metrics/'piezas.yml'
276
+ if path.exists() or path.is_symlink():
277
+ def pieces():
278
+ rows=yaml.safe_load(_safe(path,root).read_text())
279
+ if not isinstance(rows,list): raise ValueError('Invalid editorial store')
280
+ altered=False
281
+ for row in rows:
282
+ if isinstance(row,dict) and row.get('red')=='youtube' and (purge or expired(row.get('api_observed_at'),now)):
283
+ for field in ('id','titulo','tipo','publicado_el','duracion_seg','api_observed_at'):
284
+ if field in row: del row[field]; altered=True
285
+ row['api_data_removed']=True
286
+ if altered:
287
+ from socialctl.metricas.almacen import escribir_atomico
288
+ escribir_atomico(path,yaml.safe_dump(rows,allow_unicode=True,sort_keys=False)); changed(path)
289
+ run(path,pieces)
290
+ path=metrics/'resumen.md'
291
+ if path.exists() or path.is_symlink():
292
+ def summary():
293
+ text=_safe(path,root).read_text()
294
+ # Generated summaries do not record per-section provenance; remove the
295
+ # YouTube section on every lifecycle pass and regenerate on fresh stats.
296
+ cleaned=re.sub(r'^## youtube\n.*?(?=^## |\Z)', '', text, flags=re.M|re.S)
297
+ if cleaned!=text:
298
+ from socialctl.metricas.almacen import escribir_atomico
299
+ escribir_atomico(path,cleaned);changed(path)
300
+ run(path,summary)
301
+
302
+ path=root/'.socialctl/unified-inbox/state.json'
303
+ if path.exists() or path.is_symlink():
304
+ def inbox():
305
+ with _lock(_safe(root/'.socialctl/comment-inbox/state.lock',root)):
306
+ data=read(path); old=json.dumps(data,sort_keys=True)
307
+ data['items']={k:r for k,r in data['items'].items() if r.get('platform')!='youtube' or not (purge or expired(r.get('first_observed_at'),now))}
308
+ data['watermarks']={k:v for k,v in data['watermarks'].items() if json.loads(k)[0]!='youtube' or not (purge or expired(v,now))}
309
+ if json.dumps(data,sort_keys=True)!=old: save(path,data)
310
+ run(path,inbox)
311
+ for path in files(root/'.socialctl/inventory/youtube','*/snapshot.json'):
312
+ def inventory():
313
+ with _lock(_safe(path.with_name('sync.lock'),root)):
314
+ data=read(path); old=json.dumps(data,sort_keys=True)
315
+ data['items']={k:r for k,r in data.get('items',{}).items() if not (purge or expired(r.get('first_observed_at'),now))}
316
+ sync=data.get('sync')
317
+ if sync and (purge or expired(sync.get('started_at'),now)): data['sync']=None
318
+ if json.dumps(data,sort_keys=True)!=old: save(path,data)
319
+ run(path,inventory)
320
+
321
+ observations=root/'.socialctl/youtube-observations'
322
+ def observation_store():
323
+ import uuid
324
+ lock_name=str(uuid.uuid5(uuid.NAMESPACE_URL,'youtube-observations-index'))+'.lock'
325
+ # Match ingestion's lock across file removal and index replacement, so a
326
+ # concurrent supplied Studio import cannot lose its committed index entry.
327
+ with _lock(_safe(observations/lock_name,root)):
328
+ removed=set()
329
+ for path in files(observations,'*.json'):
330
+ if path.name=='index.json': continue
331
+ def observation():
332
+ data=read(path); provenance=data.get('provenance',{})
333
+ if provenance.get('source')=='youtubeStudio':return
334
+ if purge or expired(provenance.get('observed_at'),now):
335
+ remove(path.with_suffix('.csv'));remove(path);removed.add(path.stem)
336
+ run(path,observation)
337
+ index=observations/'index.json'
338
+ if removed and index.exists():
339
+ def checkpoint():
340
+ data=read(index)
341
+ data['imports']={k:v for k,v in data['imports'].items() if k not in removed}
342
+ data['active']={k:v for k,v in data['active'].items() if v not in removed}
343
+ save(index,data)
344
+ run(index,checkpoint)
345
+ for path in files(observations,'*.csv'):
346
+ if not path.with_suffix('.json').exists():
347
+ # Unclassified orphan bytes could be supplied Studio input: fail closed.
348
+ fail(path,ValueError('Orphan CSV requires provenance review'))
349
+ if observations.exists() or observations.is_symlink():run(observations,observation_store)
350
+
351
+ for folder in (*JOURNALS,'publications'):
352
+ for path in files(root/'.socialctl'/folder,'*.json'):
353
+ def journal():
354
+ with _lock(_safe(path.with_suffix('.lock'),root)):
355
+ data=read(path)
356
+ if data.get('data_lifecycle')=='purged': return
357
+ if folder=='publications' and data.get('platform')!='youtube':return
358
+ if folder=='community-batches' and not any(r.get('provider')=='youtube-community' for r in data.get('children',[])):return
359
+ if purge or expired(_age(data),now):
360
+ save(path,_tombstone(data));result['manual_review'].append(str(path.relative_to(root)))
361
+ remove(path.with_suffix('.caption-original'))
362
+ run(path,journal)
363
+ for path in files(root/'.socialctl/youtube-resources','*.caption-original'):
364
+ if not path.with_suffix('.json').exists(): run(path,lambda:remove(path))
365
+
366
+ # Result files are mixed-platform retry evidence. Keep a permanent uncertain
367
+ # YouTube row after removing remote IDs, URLs, errors and observations.
368
+ for path in files(root/'posts','*/resultado.json'):
369
+ def publication_result():
370
+ data=read(path); altered=False
371
+ if data is None:return # No provider data; existing ambiguity guard remains.
372
+ if isinstance(data,list):rows=data;stamp=None
373
+ elif isinstance(data,dict):
374
+ rows=data.get('resultados',data.get('results',[data] if 'platform' in data else []));stamp=data.get('actualizado')
375
+ else:raise ValueError('Invalid result evidence')
376
+ for i,row in enumerate(rows):
377
+ if not isinstance(row,dict):raise ValueError('Invalid result row')
378
+ if row.get('platform')=='youtube' and not row.get('api_data_removed') and (purge or expired(row.get('fecha') or stamp,now)):
379
+ clean={'platform':'youtube','status':'pendiente_confirmacion','riesgo_duplicado':True,
380
+ 'api_data_removed':True,'error':'API data expired; reconcile manually before any new upload.'}
381
+ if rows==[data] and isinstance(data,dict) and 'platform' in data:data=clean
382
+ else:rows[i]=clean
383
+ altered=True
384
+ if altered:save(path,data)
385
+ run(path,publication_result)
386
+ path=root/'historial.md'
387
+ if path.exists() or path.is_symlink():
388
+ def history():
389
+ text=_safe(path,root).read_text()
390
+ cleaned=re.sub(r'^- youtube:.*?(?=^- (?:youtube|facebook|instagram|tiktok):|^## |\Z)', '- youtube: API receipt removed; do not infer permission to repeat the upload.\n',text,flags=re.M|re.S)
391
+ if cleaned!=text:
392
+ from socialctl.metricas.almacen import escribir_atomico
393
+ escribir_atomico(path,cleaned);changed(path)
394
+ run(path,history)
395
+ # Enumerated migration backups remain protected against replay: altered
396
+ # fingerprints fail recovery rather than restoring erased provider records.
397
+ archives=[]
398
+ for directory in (root/'.socialctl/native-migrations',root/'.socialctl/migrations'):
399
+ archives.extend(files(directory,'**/*.json'))
400
+ for name in ('schedules.json','legacy-tombstones.json'):
401
+ current=root/'.socialctl'/name
402
+ if current.exists() or current.is_symlink():archives.append(current)
403
+ for path in archives:
404
+ def archive():
405
+ data=read(path);cleaned=_legacy_rows(data,now=now,purge=purge)
406
+ if cleaned!=data:
407
+ with _lock(_safe(root/'.socialctl/schedules.lock',root)):
408
+ data=read(path);cleaned=_legacy_rows(data,now=now,purge=purge)
409
+ if cleaned!=data:save(path,cleaned);result['manual_review'].append(str(path.relative_to(root)))
410
+ run(path,archive)
411
+ databases=[]
412
+ for directory in (root/'.socialctl/native-migrations',root/'.socialctl/migrations'):
413
+ databases.extend(files(directory,'**/schedules.sqlite3'))
414
+ current=root/'.socialctl/schedules.sqlite3'
415
+ if current.exists() or current.is_symlink():databases.append(current)
416
+ for path in databases:
417
+ def database():
418
+ with sqlite3.connect(_safe(path,root).as_uri()+'?mode=ro',uri=True,timeout=1) as db:
419
+ rows=[json.loads(row[0]) for row in db.execute('SELECT payload FROM entries')]
420
+ if not any(row!=_legacy_rows(row,now=now,purge=purge) for row in rows):return
421
+ with _lock(_safe(root/'.socialctl/schedules.lock',root)):
422
+ if _legacy_database(_safe(path,root),now=now,purge=purge):
423
+ changed(path);result['manual_review'].append(str(path.relative_to(root)))
424
+ run(path,database)
425
+ path=root/'native-schedules.sqlite3'
426
+ if path.exists() or path.is_symlink():
427
+ def native():
428
+ if _native_cleanup(_safe(path,root),now=now,purge=purge):
429
+ changed(path);result['manual_review'].append(str(path.relative_to(root)))
430
+ run(path,native)
431
+ if result['complete'] and marker.exists(): marker.unlink()
432
+ return result
433
+
434
+
435
+ def request_youtube_purge(brand_root):
436
+ root=Path(brand_root).absolute()
437
+ marker=_safe(root/'.socialctl/youtube-purge-required.json',root)
438
+ marker.parent.mkdir(parents=True,exist_ok=True)
439
+ write_json(marker,{'version':1,'purge_required':True})
440
+ return cleanup_youtube_data(root,purge=True)
441
+
442
+
443
+ def enforce_youtube_data(brand_root):
444
+ result=cleanup_youtube_data(brand_root)
445
+ if not result['complete']:
446
+ busy=[item['path'] for item in result['errors'] if item['code']=='local_data_busy']
447
+ failed=[item['path'] for item in result['errors'] if item['code']!='local_data_busy']
448
+ message=''
449
+ if busy:
450
+ message='YouTube local data cleanup is waiting for another SocialCli operation: '+', '.join(busy)+'. Wait for it to finish, then retry this command. Do not delete or repair these files. '
451
+ if failed:
452
+ message+='YouTube local data cleanup is incomplete (unreadable cache or journal): '+', '.join(failed)+'. Run data-cleanup --brand BRAND --root WORKSPACE and resolve these paths before reading or writing this brand.'
453
+ raise (YouTubeDataCleanupError if failed else YouTubeDataBusy)(message.strip())
454
+ return result
455
+
456
+
457
+ def register(app):
458
+ import typer
459
+ @app.command('data-cleanup')
460
+ def data_cleanup(brand: str=typer.Option(...,'--brand'), root:Path=typer.Option(...,'--root'),
461
+ purge_youtube:bool=typer.Option(False,'--purge-youtube',help='Remove all managed YouTube API data now; preserve supplied originals and replay guards.')):
462
+ from socialctl.brands import cargar_brand
463
+ selected=cargar_brand(root,brand,cleanup=False)
464
+ result=request_youtube_purge(selected.raiz) if purge_youtube else cleanup_youtube_data(selected.raiz)
465
+ typer.echo(json.dumps(result,ensure_ascii=False))
466
+ if not result['complete']:raise typer.Exit(1)
467
+
468
+
469
+ def require_fresh_api_data(stamp):
470
+ if expired(stamp,datetime.now(timezone.utc)):
471
+ raise ValueError('YouTube API data expired; run data-cleanup for this brand before accessing this store.')