@maccesar/aiskills 1.22.0 → 1.24.0

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.
@@ -0,0 +1,517 @@
1
+ #!/usr/bin/env python3
2
+
3
+ """Safely publish a prepared technical-demo package through YouTube Data API."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import hashlib
9
+ import json
10
+ import os
11
+ import sys
12
+ from pathlib import Path
13
+
14
+
15
+ SCOPE = 'https://www.googleapis.com/auth/youtube.force-ssl'
16
+ PUBLIC_STATES = {'public', 'unlisted'}
17
+
18
+
19
+ def parse_args():
20
+ parser = argparse.ArgumentParser()
21
+ parser.add_argument('manifest', type=Path, nargs='?')
22
+ parser.add_argument('--client-secrets', type=Path)
23
+ parser.add_argument('--token', type=Path)
24
+ parser.add_argument('--receipt', type=Path)
25
+ parser.add_argument(
26
+ '--inspect-account', action='store_true',
27
+ help='list the authenticated channel and playlists without uploading'
28
+ )
29
+ parser.add_argument('--execute', action='store_true')
30
+ parser.add_argument(
31
+ '--confirm-plan',
32
+ help='confirmation token printed by the approved dry run'
33
+ )
34
+ parser.add_argument('--allow-public', action='store_true')
35
+ parser.add_argument(
36
+ '--replace-captions', action='store_true',
37
+ help='replace the caption file for the track stored in the receipt'
38
+ )
39
+ return parser.parse_args()
40
+
41
+
42
+ def sha256(path):
43
+ digest = hashlib.sha256()
44
+ with path.open('rb') as handle:
45
+ for chunk in iter(lambda: handle.read(1024 * 1024), b''):
46
+ digest.update(chunk)
47
+ return digest.hexdigest()
48
+
49
+
50
+ def metadata_sha256(payload):
51
+ artifact_keys = {'videoFile', 'captionsFile', 'thumbnailFile'}
52
+ metadata = {
53
+ key: value for key, value in payload.items()
54
+ if key not in artifact_keys
55
+ }
56
+ serialized = json.dumps(
57
+ metadata, sort_keys=True, separators=(',', ':')
58
+ ).encode()
59
+ return hashlib.sha256(serialized).hexdigest()
60
+
61
+
62
+ def confirmation_token(video_hash, metadata_hash):
63
+ return hashlib.sha256(
64
+ f'{video_hash}:{metadata_hash}'.encode()
65
+ ).hexdigest()
66
+
67
+
68
+ def resolve_optional(root, value):
69
+ if value in (None, ''):
70
+ return None
71
+ path = (root / value).resolve()
72
+ if not path.is_file():
73
+ raise RuntimeError(f'file not found: {path}')
74
+ return path
75
+
76
+
77
+ def load_manifest(path):
78
+ path = path.resolve()
79
+ payload = json.loads(path.read_text())
80
+ if not isinstance(payload, dict):
81
+ raise RuntimeError('manifest must contain a JSON object')
82
+ if payload.get('schemaVersion') != 1:
83
+ raise RuntimeError('manifest schemaVersion must be 1')
84
+ for key in ('videoFile', 'title', 'description', 'categoryId'):
85
+ if not payload.get(key):
86
+ raise RuntimeError(f'manifest requires {key}')
87
+ for key in (
88
+ 'expectedChannelId', 'playlistId', 'privacyStatus', 'publishAt',
89
+ 'captionsFile', 'thumbnailFile'
90
+ ):
91
+ if key not in payload:
92
+ raise RuntimeError(f'manifest requires explicit {key}')
93
+ if not isinstance(payload['expectedChannelId'], str) or not payload['expectedChannelId'].strip():
94
+ raise RuntimeError('expectedChannelId must be a non-empty channel ID')
95
+ if payload['playlistId'] is not None and (
96
+ not isinstance(payload['playlistId'], str) or
97
+ not payload['playlistId'].strip()
98
+ ):
99
+ raise RuntimeError('playlistId must be a non-empty ID or null')
100
+ if len(payload['title']) > 100:
101
+ raise RuntimeError('YouTube title exceeds 100 characters')
102
+ if len(payload['description']) > 5000:
103
+ raise RuntimeError('YouTube description exceeds 5000 characters')
104
+ privacy = payload.get('privacyStatus', 'private')
105
+ if privacy not in {'private', 'unlisted', 'public'}:
106
+ raise RuntimeError('privacyStatus must be private, unlisted, or public')
107
+ if payload.get('publishAt') and privacy != 'private':
108
+ raise RuntimeError('publishAt requires privacyStatus private')
109
+ if payload['captionsFile'] is not None and not isinstance(
110
+ payload.get('caption'), dict
111
+ ):
112
+ raise RuntimeError('caption settings are required when captionsFile is set')
113
+
114
+ root = path.parent
115
+ files = {
116
+ 'video': resolve_optional(root, payload['videoFile']),
117
+ 'captions': resolve_optional(root, payload.get('captionsFile')),
118
+ 'thumbnail': resolve_optional(root, payload.get('thumbnailFile'))
119
+ }
120
+ return path, payload, files
121
+
122
+
123
+ def planned_operations(payload, files, receipt, replace_captions=False):
124
+ operations = []
125
+ if not receipt.get('videoId'):
126
+ operations.append({
127
+ 'operation': 'videos.insert',
128
+ 'file': str(files['video']),
129
+ 'privacyStatus': payload.get('privacyStatus', 'private'),
130
+ 'publishAt': payload.get('publishAt')
131
+ })
132
+ if payload.get('playlistId') and not receipt.get('playlistInserted'):
133
+ operations.append({
134
+ 'operation': 'playlistItems.insert',
135
+ 'playlistId': payload['playlistId']
136
+ })
137
+ if files['captions'] and (
138
+ not receipt.get('captionsUploaded') or replace_captions
139
+ ):
140
+ operations.append({
141
+ 'operation': (
142
+ 'captions.update' if replace_captions else 'captions.insert'
143
+ ),
144
+ 'file': str(files['captions']),
145
+ 'language': payload.get('caption', {}).get('language', 'en')
146
+ })
147
+ if files['thumbnail'] and not receipt.get('thumbnailUploaded'):
148
+ operations.append({
149
+ 'operation': 'thumbnails.set',
150
+ 'file': str(files['thumbnail'])
151
+ })
152
+ return operations
153
+
154
+
155
+ def load_google_clients():
156
+ try:
157
+ from google.auth.transport.requests import Request
158
+ from google.oauth2.credentials import Credentials
159
+ from google_auth_oauthlib.flow import InstalledAppFlow
160
+ from googleapiclient.discovery import build
161
+ from googleapiclient.http import MediaFileUpload
162
+ except ImportError as exc:
163
+ raise RuntimeError(
164
+ 'missing Google clients; install google-api-python-client, '
165
+ 'google-auth-oauthlib, and google-auth-httplib2'
166
+ ) from exc
167
+ return Request, Credentials, InstalledAppFlow, build, MediaFileUpload
168
+
169
+
170
+ def authenticate(client_secrets, token_path, google):
171
+ Request, Credentials, InstalledAppFlow, build, _ = google
172
+ credentials = None
173
+ if token_path.is_file():
174
+ credentials = Credentials.from_authorized_user_file(token_path, [SCOPE])
175
+ if credentials and credentials.expired and credentials.refresh_token:
176
+ credentials.refresh(Request())
177
+ if not credentials or not credentials.valid:
178
+ flow = InstalledAppFlow.from_client_secrets_file(
179
+ str(client_secrets), [SCOPE]
180
+ )
181
+ credentials = flow.run_local_server(port=0)
182
+ token_path.parent.mkdir(parents=True, exist_ok=True)
183
+ token_path.write_text(credentials.to_json())
184
+ os.chmod(token_path, 0o600)
185
+ return build('youtube', 'v3', credentials=credentials)
186
+
187
+
188
+ def authenticated_channels(youtube):
189
+ response = youtube.channels().list(
190
+ part='id,snippet', mine=True, maxResults=50
191
+ ).execute()
192
+ channels = [
193
+ {
194
+ 'id': item['id'],
195
+ 'title': item.get('snippet', {}).get('title')
196
+ }
197
+ for item in response.get('items', [])
198
+ ]
199
+ if not channels:
200
+ raise RuntimeError('OAuth token has no accessible YouTube channel')
201
+ return channels
202
+
203
+
204
+ def verify_expected_channel(youtube, expected_channel_id):
205
+ channels = authenticated_channels(youtube)
206
+ selected = next(
207
+ (channel for channel in channels if channel['id'] == expected_channel_id),
208
+ None
209
+ )
210
+ if selected is None:
211
+ actual = ', '.join(channel['id'] for channel in channels)
212
+ raise RuntimeError(
213
+ f'authenticated channel mismatch: expected {expected_channel_id}; '
214
+ f'token provides {actual}'
215
+ )
216
+ return selected
217
+
218
+
219
+ def authenticated_playlists(youtube):
220
+ playlists = []
221
+ page_token = None
222
+ while True:
223
+ response = youtube.playlists().list(
224
+ part='id,snippet', mine=True, maxResults=50,
225
+ pageToken=page_token
226
+ ).execute()
227
+ playlists.extend({
228
+ 'id': item['id'],
229
+ 'title': item.get('snippet', {}).get('title'),
230
+ 'channelId': item.get('snippet', {}).get('channelId')
231
+ } for item in response.get('items', []))
232
+ page_token = response.get('nextPageToken')
233
+ if not page_token:
234
+ return playlists
235
+
236
+
237
+ def verify_playlist(youtube, playlist_id, expected_channel_id):
238
+ if playlist_id is None:
239
+ return None
240
+ response = youtube.playlists().list(
241
+ part='id,snippet', id=playlist_id, maxResults=1
242
+ ).execute()
243
+ items = response.get('items', [])
244
+ if not items:
245
+ raise RuntimeError(f'playlist not found or inaccessible: {playlist_id}')
246
+ snippet = items[0].get('snippet', {})
247
+ actual_channel_id = snippet.get('channelId')
248
+ if actual_channel_id != expected_channel_id:
249
+ raise RuntimeError(
250
+ f'playlist channel mismatch: expected {expected_channel_id}; '
251
+ f'playlist belongs to {actual_channel_id}'
252
+ )
253
+ return {
254
+ 'id': items[0]['id'],
255
+ 'title': snippet.get('title'),
256
+ 'channelId': actual_channel_id
257
+ }
258
+
259
+
260
+ def require_auth_paths(args):
261
+ if not args.client_secrets or not args.client_secrets.is_file():
262
+ raise RuntimeError('--client-secrets must identify the OAuth client JSON')
263
+ if not args.token:
264
+ raise RuntimeError('--token must identify an external token JSON path')
265
+
266
+
267
+ def save_receipt(path, receipt):
268
+ temporary = path.with_suffix(path.suffix + '.tmp')
269
+ temporary.write_text(json.dumps(receipt, indent=2) + '\n')
270
+ temporary.replace(path)
271
+
272
+
273
+ def resumable_upload(request):
274
+ response = None
275
+ while response is None:
276
+ status, response = request.next_chunk()
277
+ if status:
278
+ print(f'upload: {round(status.progress() * 100, 1)}%')
279
+ return response
280
+
281
+
282
+ def main():
283
+ args = parse_args()
284
+ if args.inspect_account:
285
+ if args.manifest:
286
+ raise RuntimeError('--inspect-account does not accept a manifest')
287
+ if (
288
+ args.execute or args.confirm_plan or args.allow_public or
289
+ args.replace_captions or args.receipt
290
+ ):
291
+ raise RuntimeError('--inspect-account cannot be combined with upload flags')
292
+ require_auth_paths(args)
293
+ google = load_google_clients()
294
+ youtube = authenticate(
295
+ args.client_secrets.resolve(), args.token.resolve(), google
296
+ )
297
+ print(json.dumps({
298
+ 'mode': 'inspect-account',
299
+ 'channels': authenticated_channels(youtube),
300
+ 'playlists': authenticated_playlists(youtube)
301
+ }, indent=2))
302
+ return
303
+
304
+ if args.manifest is None:
305
+ raise RuntimeError('manifest is required unless --inspect-account is used')
306
+ manifest_path, manifest, files = load_manifest(args.manifest)
307
+ receipt_path = (
308
+ args.receipt.resolve() if args.receipt else
309
+ manifest_path.with_name(
310
+ manifest_path.stem.replace('-upload', '-upload-receipt') + '.json'
311
+ )
312
+ )
313
+ video_hash = sha256(files['video'])
314
+ metadata_hash = metadata_sha256(manifest)
315
+ plan_confirmation = confirmation_token(video_hash, metadata_hash)
316
+ receipt = {}
317
+ if receipt_path.is_file():
318
+ receipt = json.loads(receipt_path.read_text())
319
+ if receipt.get('videoSha256') != video_hash:
320
+ raise RuntimeError('receipt video hash differs; refusing a duplicate upload')
321
+ if (
322
+ receipt.get('metadataSha256') and
323
+ receipt['metadataSha256'] != metadata_hash
324
+ ):
325
+ raise RuntimeError('receipt metadata differs; review before continuing')
326
+
327
+ if args.replace_captions and not receipt.get('captionId'):
328
+ raise RuntimeError(
329
+ '--replace-captions requires captionId in the upload receipt'
330
+ )
331
+ if files['captions'] and receipt.get('captionsSha256'):
332
+ captions_hash = sha256(files['captions'])
333
+ if (
334
+ captions_hash != receipt['captionsSha256'] and
335
+ not args.replace_captions
336
+ ):
337
+ raise RuntimeError(
338
+ 'caption file differs from receipt; use --replace-captions after review'
339
+ )
340
+ if files['thumbnail'] and receipt.get('thumbnailSha256'):
341
+ if sha256(files['thumbnail']) != receipt['thumbnailSha256']:
342
+ raise RuntimeError('thumbnail file differs from receipt; review before continuing')
343
+
344
+ plan = planned_operations(
345
+ manifest, files, receipt, args.replace_captions
346
+ )
347
+ print(json.dumps({
348
+ 'mode': 'execute' if args.execute else 'dry-run',
349
+ 'manifest': str(manifest_path),
350
+ 'videoSha256': video_hash,
351
+ 'metadataSha256': metadata_hash,
352
+ 'confirmationToken': plan_confirmation,
353
+ 'target': {
354
+ 'expectedChannelId': manifest['expectedChannelId'],
355
+ 'playlistId': manifest['playlistId'],
356
+ 'privacyStatus': manifest['privacyStatus'],
357
+ 'publishAt': manifest['publishAt'],
358
+ 'captionsFile': manifest['captionsFile'],
359
+ 'thumbnailFile': manifest['thumbnailFile']
360
+ },
361
+ 'operations': plan
362
+ }, indent=2))
363
+ if not args.execute:
364
+ return
365
+
366
+ if args.confirm_plan != plan_confirmation:
367
+ raise RuntimeError(
368
+ '--execute requires --confirm-plan with the token from the approved dry run'
369
+ )
370
+
371
+ privacy = manifest['privacyStatus']
372
+ if (privacy in PUBLIC_STATES or manifest.get('publishAt')) and not args.allow_public:
373
+ raise RuntimeError(
374
+ 'public, unlisted, or scheduled publication requires --allow-public'
375
+ )
376
+ require_auth_paths(args)
377
+
378
+ google = load_google_clients()
379
+ youtube = authenticate(
380
+ args.client_secrets.resolve(), args.token.resolve(), google
381
+ )
382
+ channel = verify_expected_channel(
383
+ youtube, manifest['expectedChannelId']
384
+ )
385
+ playlist = verify_playlist(
386
+ youtube, manifest['playlistId'], channel['id']
387
+ )
388
+ MediaFileUpload = google[-1]
389
+
390
+ video_id = receipt.get('videoId')
391
+ if not video_id:
392
+ snippet = {
393
+ 'title': manifest['title'],
394
+ 'description': manifest['description'],
395
+ 'tags': manifest.get('tags', []),
396
+ 'categoryId': str(manifest['categoryId']),
397
+ 'defaultLanguage': manifest.get('defaultLanguage', 'en')
398
+ }
399
+ status = {
400
+ 'privacyStatus': privacy,
401
+ 'selfDeclaredMadeForKids': bool(
402
+ manifest.get('selfDeclaredMadeForKids', False)
403
+ ),
404
+ 'embeddable': bool(manifest.get('embeddable', True)),
405
+ 'license': manifest.get('license', 'youtube')
406
+ }
407
+ if manifest.get('publishAt'):
408
+ status['publishAt'] = manifest['publishAt']
409
+ request = youtube.videos().insert(
410
+ part='snippet,status',
411
+ body={'snippet': snippet, 'status': status},
412
+ media_body=MediaFileUpload(
413
+ str(files['video']), mimetype='video/mp4',
414
+ chunksize=8 * 1024 * 1024, resumable=True
415
+ )
416
+ )
417
+ response = resumable_upload(request)
418
+ video_id = response['id']
419
+ receipt.update({
420
+ 'schemaVersion': 1,
421
+ 'manifest': str(manifest_path),
422
+ 'videoSha256': video_hash,
423
+ 'metadataSha256': metadata_hash,
424
+ 'channelId': channel['id'],
425
+ 'channelTitle': channel['title'],
426
+ 'playlistId': playlist['id'] if playlist else None,
427
+ 'playlistTitle': playlist['title'] if playlist else None,
428
+ 'videoId': video_id,
429
+ 'url': f'https://youtu.be/{video_id}',
430
+ 'videoUploaded': True
431
+ })
432
+ save_receipt(receipt_path, receipt)
433
+
434
+ if manifest.get('playlistId') and not receipt.get('playlistInserted'):
435
+ response = youtube.playlistItems().insert(
436
+ part='snippet',
437
+ body={'snippet': {
438
+ 'playlistId': manifest['playlistId'],
439
+ 'resourceId': {'kind': 'youtube#video', 'videoId': video_id}
440
+ }}
441
+ ).execute()
442
+ receipt.update({
443
+ 'playlistInserted': True,
444
+ 'playlistItemId': response['id']
445
+ })
446
+ save_receipt(receipt_path, receipt)
447
+
448
+ if files['captions'] and (
449
+ not receipt.get('captionsUploaded') or args.replace_captions
450
+ ):
451
+ caption = manifest.get('caption', {})
452
+ media = MediaFileUpload(
453
+ str(files['captions']), mimetype='application/octet-stream'
454
+ )
455
+ if args.replace_captions:
456
+ caption_id = receipt['captionId']
457
+ response = youtube.captions().update(
458
+ part='id', body={'id': caption_id}, media_body=media
459
+ ).execute()
460
+ else:
461
+ response = youtube.captions().insert(
462
+ part='snippet',
463
+ body={'snippet': {
464
+ 'videoId': video_id,
465
+ 'language': caption.get('language', 'en'),
466
+ 'name': caption.get('name', 'English'),
467
+ 'isDraft': bool(caption.get('isDraft', False))
468
+ }},
469
+ media_body=media
470
+ ).execute()
471
+ receipt.update({
472
+ 'captionsUploaded': True,
473
+ 'captionId': response['id'],
474
+ 'captionsSha256': sha256(files['captions'])
475
+ })
476
+ save_receipt(receipt_path, receipt)
477
+
478
+ if files['thumbnail'] and not receipt.get('thumbnailUploaded'):
479
+ youtube.thumbnails().set(
480
+ videoId=video_id,
481
+ media_body=MediaFileUpload(str(files['thumbnail']))
482
+ ).execute()
483
+ receipt['thumbnailUploaded'] = True
484
+ receipt['thumbnailSha256'] = sha256(files['thumbnail'])
485
+ save_receipt(receipt_path, receipt)
486
+
487
+ verification = youtube.videos().list(
488
+ part='status,processingDetails', id=video_id
489
+ ).execute()
490
+ items = verification.get('items', [])
491
+ if not items:
492
+ raise RuntimeError('uploaded video could not be read back for verification')
493
+ verified_video = items[0]
494
+ actual_privacy = verified_video.get('status', {}).get('privacyStatus')
495
+ if actual_privacy != privacy:
496
+ raise RuntimeError(
497
+ f'privacy verification failed: expected {privacy}, found {actual_privacy}'
498
+ )
499
+ receipt['verification'] = {
500
+ 'privacyStatus': actual_privacy,
501
+ 'uploadStatus': verified_video.get('status', {}).get('uploadStatus'),
502
+ 'processingStatus': verified_video.get(
503
+ 'processingDetails', {}
504
+ ).get('processingStatus')
505
+ }
506
+ receipt.setdefault('metadataSha256', metadata_hash)
507
+ save_receipt(receipt_path, receipt)
508
+
509
+ print(json.dumps(receipt, indent=2))
510
+
511
+
512
+ if __name__ == '__main__':
513
+ try:
514
+ main()
515
+ except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as exc:
516
+ print(f'error: {exc}', file=sys.stderr)
517
+ raise SystemExit(1)