@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.
- package/README.md +84 -75
- package/lib/config.js +1 -0
- package/package.json +1 -1
- package/skills/humaniza/SKILL.md +39 -1
- package/skills/technical-demo-videos/SKILL.md +146 -0
- package/skills/technical-demo-videos/agents/openai.yaml +4 -0
- package/skills/technical-demo-videos/evals/evals.json +62 -0
- package/skills/technical-demo-videos/references/audio-timing.md +40 -0
- package/skills/technical-demo-videos/references/package-contract.md +33 -0
- package/skills/technical-demo-videos/references/publishing-metadata.md +36 -0
- package/skills/technical-demo-videos/references/recording-workflow.md +40 -0
- package/skills/technical-demo-videos/references/story-direction.md +37 -0
- package/skills/technical-demo-videos/references/vertical-social-video.md +24 -0
- package/skills/technical-demo-videos/references/vscode-default-profile.md +49 -0
- package/skills/technical-demo-videos/references/youtube-master.md +42 -0
- package/skills/technical-demo-videos/references/youtube-publishing.md +126 -0
- package/skills/technical-demo-videos/scripts/events_to_cues.py +89 -0
- package/skills/technical-demo-videos/scripts/normalize_youtube_master.py +210 -0
- package/skills/technical-demo-videos/scripts/recording_runtime.py +427 -0
- package/skills/technical-demo-videos/scripts/youtube_publish.py +517 -0
|
@@ -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)
|