@opencoredev/social-sdk 0.1.2 → 0.2.1
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 +6 -0
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +234 -0
- package/dist/cloud/common.d.ts +25 -0
- package/dist/cloud/common.js +334 -0
- package/dist/cloud/lifecycle.d.ts +10 -0
- package/dist/cloud/lifecycle.js +112 -0
- package/dist/cloud/media.d.ts +23 -0
- package/dist/cloud/media.js +100 -0
- package/dist/cloud/outcomes.d.ts +9 -0
- package/dist/cloud/outcomes.js +195 -0
- package/dist/cloud/post-for-me.d.ts +69 -0
- package/dist/cloud/post-for-me.js +396 -0
- package/dist/cloud/zernio.d.ts +111 -0
- package/dist/cloud/zernio.js +632 -0
- package/dist/core/adapter.d.ts +158 -0
- package/dist/core/adapter.js +3 -0
- package/dist/core/client.d.ts +141 -0
- package/dist/core/client.js +1285 -0
- package/dist/core/concurrency.d.ts +9 -0
- package/dist/core/concurrency.js +79 -0
- package/dist/core/errors.d.ts +44 -0
- package/dist/core/errors.js +50 -0
- package/dist/core/idempotency.d.ts +33 -0
- package/dist/core/idempotency.js +58 -0
- package/dist/core/index.d.ts +6 -0
- package/dist/core/index.js +6 -0
- package/dist/core/pagination.d.ts +11 -0
- package/dist/core/pagination.js +81 -0
- package/dist/core/types.d.ts +396 -0
- package/dist/core/types.js +16 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/platforms/bluesky.d.ts +261 -0
- package/dist/platforms/bluesky.js +1776 -0
- package/dist/platforms/instagram.d.ts +129 -0
- package/dist/platforms/instagram.js +1031 -0
- package/dist/platforms/linkedin.d.ts +108 -0
- package/dist/platforms/linkedin.js +890 -0
- package/dist/platforms/threads.d.ts +134 -0
- package/dist/platforms/threads.js +945 -0
- package/dist/platforms/tiktok.d.ts +31 -0
- package/dist/platforms/tiktok.js +593 -0
- package/dist/platforms/x-engagement.d.ts +16 -0
- package/dist/platforms/x-engagement.js +50 -0
- package/dist/platforms/x-text.d.ts +2 -0
- package/dist/platforms/x-text.js +123 -0
- package/dist/platforms/x-tlds.d.ts +1 -0
- package/dist/platforms/x-tlds.js +2 -0
- package/dist/platforms/x.d.ts +299 -0
- package/dist/platforms/x.js +1287 -0
- package/dist/platforms/youtube-upload.d.ts +32 -0
- package/dist/platforms/youtube-upload.js +296 -0
- package/dist/platforms/youtube.d.ts +108 -0
- package/dist/platforms/youtube.js +1100 -0
- package/dist/server/connections.d.ts +123 -0
- package/dist/server/connections.js +335 -0
- package/dist/server/credentials.d.ts +51 -0
- package/dist/server/credentials.js +108 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.js +4 -0
- package/dist/server/oauth.d.ts +52 -0
- package/dist/server/oauth.js +553 -0
- package/dist/server/webhooks.d.ts +57 -0
- package/dist/server/webhooks.js +204 -0
- package/dist/testing/index.d.ts +46 -0
- package/dist/testing/index.js +564 -0
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +1 -0
- package/dist/transport/binary.d.ts +2 -0
- package/dist/transport/binary.js +29 -0
- package/dist/transport/budget.d.ts +3 -0
- package/dist/transport/budget.js +26 -0
- package/dist/transport/http.d.ts +44 -0
- package/dist/transport/http.js +259 -0
- package/dist/transport/json.d.ts +8 -0
- package/dist/transport/json.js +16 -0
- package/dist/transport/upload.d.ts +25 -0
- package/dist/transport/upload.js +153 -0
- package/dist/transport/validation.d.ts +5 -0
- package/dist/transport/validation.js +24 -0
- package/package.json +2 -7
|
@@ -0,0 +1,1100 @@
|
|
|
1
|
+
/* oxlint-disable anti-slop/no-conditional-empty-object-spread, anti-slop/no-runtime-typeof, anti-slop/require-safety-comment-for-type-assertion, anti-slop/require-readable-spacing, anti-slop/no-chained-type-assertions -- validated external boundary or fixture contract. */
|
|
2
|
+
import { remainingBudget } from "../transport/budget.js";
|
|
3
|
+
import { defineAdapter } from "../core/adapter.js";
|
|
4
|
+
import { SocialError } from "../core/errors.js";
|
|
5
|
+
import { managedHttp, publicFields } from "../cloud/common.js";
|
|
6
|
+
import { createHttp, HttpError } from "../transport/http.js";
|
|
7
|
+
import { array, object, optionalString, string } from "../transport/validation.js";
|
|
8
|
+
import { beginYouTubeUpload, queryYouTubeUpload, sendYouTubeUpload, } from "./youtube-upload.js";
|
|
9
|
+
export function youtube(options) {
|
|
10
|
+
const request = managedHttp("https://www.googleapis.com", {
|
|
11
|
+
apiKey: options.auth.accessToken,
|
|
12
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
13
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
14
|
+
});
|
|
15
|
+
const analyticsRequest = managedHttp("https://youtubeanalytics.googleapis.com", {
|
|
16
|
+
apiKey: options.auth.accessToken,
|
|
17
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
18
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
19
|
+
});
|
|
20
|
+
const binaryRequest = createHttp({
|
|
21
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
22
|
+
timeoutMs: 30_000,
|
|
23
|
+
});
|
|
24
|
+
const now = () => (options.clock?.() ?? new Date()).toISOString();
|
|
25
|
+
const authorize = (ref, context) => {
|
|
26
|
+
if (ref.backend !== context.backendInstance ||
|
|
27
|
+
ref.platform !== "youtube" ||
|
|
28
|
+
ref.accountId !== options.auth.channelId)
|
|
29
|
+
throw new SocialError({
|
|
30
|
+
code: "unauthorized",
|
|
31
|
+
operation: "youtube",
|
|
32
|
+
message: "The selected channel does not belong to these configured credentials.",
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
const get = async (ref, context) => {
|
|
36
|
+
authorize(ref, context);
|
|
37
|
+
const result = object(await request("/youtube/v3/videos", context, undefined, {
|
|
38
|
+
id: ref.postId,
|
|
39
|
+
part: "snippet,status,statistics,processingDetails",
|
|
40
|
+
}));
|
|
41
|
+
const video = array(result["items"])
|
|
42
|
+
.map(object)
|
|
43
|
+
.find((item) => item["id"] === ref.postId);
|
|
44
|
+
if (!video)
|
|
45
|
+
throw new SocialError({
|
|
46
|
+
code: "upstream_failure",
|
|
47
|
+
operation: "posts.read",
|
|
48
|
+
message: "Video is absent or inaccessible to this authorization.",
|
|
49
|
+
});
|
|
50
|
+
const snippet = object(video["snippet"]);
|
|
51
|
+
if (snippet["channelId"] !== options.auth.channelId)
|
|
52
|
+
throw new SocialError({
|
|
53
|
+
code: "unauthorized",
|
|
54
|
+
operation: "posts.read",
|
|
55
|
+
message: "Video belongs to a different channel.",
|
|
56
|
+
});
|
|
57
|
+
return video;
|
|
58
|
+
};
|
|
59
|
+
const outcome = (
|
|
60
|
+
// oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- validated boundary or fixture contract.
|
|
61
|
+
video, target) => {
|
|
62
|
+
const id = string(video["id"]);
|
|
63
|
+
const status = object(video["status"]);
|
|
64
|
+
const uploaded = optionalString(status["uploadStatus"]) ?? "unknown";
|
|
65
|
+
const base = {
|
|
66
|
+
...target,
|
|
67
|
+
observedAt: now(),
|
|
68
|
+
backendState: uploaded,
|
|
69
|
+
delivery: {
|
|
70
|
+
kind: "delivery",
|
|
71
|
+
version: 1,
|
|
72
|
+
backend: target.account.backend,
|
|
73
|
+
platform: "youtube",
|
|
74
|
+
accountId: target.account.accountId,
|
|
75
|
+
deliveryId: id,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
if (uploaded === "processed")
|
|
79
|
+
return {
|
|
80
|
+
...base,
|
|
81
|
+
state: "published",
|
|
82
|
+
post: {
|
|
83
|
+
kind: "platform-post",
|
|
84
|
+
version: 1,
|
|
85
|
+
backend: target.account.backend,
|
|
86
|
+
platform: "youtube",
|
|
87
|
+
accountId: target.account.accountId,
|
|
88
|
+
postId: id,
|
|
89
|
+
},
|
|
90
|
+
url: `https://www.youtube.com/watch?v=${encodeURIComponent(id)}`,
|
|
91
|
+
};
|
|
92
|
+
if (uploaded === "uploaded")
|
|
93
|
+
return { ...base, state: "processing" };
|
|
94
|
+
if (uploaded === "failed" || uploaded === "rejected")
|
|
95
|
+
return {
|
|
96
|
+
...base,
|
|
97
|
+
state: "failed",
|
|
98
|
+
code: "media_error",
|
|
99
|
+
message: "YouTube rejected or failed to process the video. Inspect channel eligibility and upload requirements.",
|
|
100
|
+
retryDisposition: { kind: "never" },
|
|
101
|
+
};
|
|
102
|
+
return {
|
|
103
|
+
...base,
|
|
104
|
+
state: "unknown",
|
|
105
|
+
reason: "unmapped-state",
|
|
106
|
+
diagnostic: "Unmapped YouTube upload status.",
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
const accountInfo = async (context) => {
|
|
110
|
+
const result = object(await request("/youtube/v3/channels", context, undefined, {
|
|
111
|
+
mine: "true",
|
|
112
|
+
part: "snippet",
|
|
113
|
+
maxResults: "50",
|
|
114
|
+
}));
|
|
115
|
+
const channel = array(result["items"])
|
|
116
|
+
.map(object)
|
|
117
|
+
.find((item) => item["id"] === options.auth.channelId);
|
|
118
|
+
if (!channel)
|
|
119
|
+
throw new SocialError({
|
|
120
|
+
code: "unauthorized",
|
|
121
|
+
operation: "accounts.read",
|
|
122
|
+
message: "Configured channel was not returned for the authorized Google account. Select a channel through the connection flow.",
|
|
123
|
+
});
|
|
124
|
+
return {
|
|
125
|
+
ref: {
|
|
126
|
+
kind: "connected-account",
|
|
127
|
+
version: 1,
|
|
128
|
+
backend: context.backendInstance,
|
|
129
|
+
platform: "youtube",
|
|
130
|
+
accountId: options.auth.channelId,
|
|
131
|
+
},
|
|
132
|
+
displayName: string(object(channel["snippet"])["title"]),
|
|
133
|
+
status: "connected",
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
const nativeAuthorize = (context) => authorize({ backend: context.backendInstance, platform: "youtube", accountId: options.auth.channelId }, context);
|
|
137
|
+
const mediaBlob = (media) => {
|
|
138
|
+
if (!media || media.source.kind !== "blob")
|
|
139
|
+
throw new SocialError({
|
|
140
|
+
code: "invalid_input",
|
|
141
|
+
operation: "native",
|
|
142
|
+
message: "This YouTube operation requires a Blob media source.",
|
|
143
|
+
});
|
|
144
|
+
return media.source.blob;
|
|
145
|
+
};
|
|
146
|
+
const binaryJson = async (path, context, body, query, method = "POST", contentType) => {
|
|
147
|
+
const url = new URL(`https://www.googleapis.com${path}`);
|
|
148
|
+
for (const [key, value] of Object.entries(query))
|
|
149
|
+
url.searchParams.set(key, value);
|
|
150
|
+
try {
|
|
151
|
+
const result = await binaryRequest({
|
|
152
|
+
url,
|
|
153
|
+
method,
|
|
154
|
+
body,
|
|
155
|
+
headers: {
|
|
156
|
+
Authorization: `Bearer ${options.auth.accessToken}`,
|
|
157
|
+
...(contentType ? { "Content-Type": contentType } : {}),
|
|
158
|
+
},
|
|
159
|
+
timeoutMs: remainingBudget(context),
|
|
160
|
+
...(context.signal ? { signal: context.signal } : {}),
|
|
161
|
+
});
|
|
162
|
+
return object(result);
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
if (!(error instanceof HttpError))
|
|
166
|
+
throw error;
|
|
167
|
+
const ambiguous = error.dispatched && (error.kind !== "http" || (error.status ?? 0) >= 500);
|
|
168
|
+
throw new SocialError({
|
|
169
|
+
code: ambiguous
|
|
170
|
+
? "ambiguous_outcome"
|
|
171
|
+
: error.status === 401
|
|
172
|
+
? "reconnect_required"
|
|
173
|
+
: error.status === 403
|
|
174
|
+
? "missing_permission"
|
|
175
|
+
: error.status === 429
|
|
176
|
+
? "rate_limited"
|
|
177
|
+
: "upstream_failure",
|
|
178
|
+
operation: path,
|
|
179
|
+
message: error.message,
|
|
180
|
+
upstreamStatus: error.status,
|
|
181
|
+
retryDisposition: ambiguous ? { kind: "reconcile-first" } : { kind: "never" },
|
|
182
|
+
cause: error,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
return defineAdapter({
|
|
187
|
+
id: "youtube",
|
|
188
|
+
capabilities: {
|
|
189
|
+
schemaVersion: 1,
|
|
190
|
+
backend: "youtube",
|
|
191
|
+
apiRevision: "YouTube Data API v3",
|
|
192
|
+
runtime: ["node22", "node24", "bun"],
|
|
193
|
+
capabilities: [
|
|
194
|
+
...[
|
|
195
|
+
"accounts.read",
|
|
196
|
+
"posts.read",
|
|
197
|
+
"posts.list",
|
|
198
|
+
"posts.status",
|
|
199
|
+
"analytics.read",
|
|
200
|
+
"analytics.account.read",
|
|
201
|
+
"comments.read",
|
|
202
|
+
"comments.write",
|
|
203
|
+
].map((operation) => ({
|
|
204
|
+
operation,
|
|
205
|
+
platform: "youtube",
|
|
206
|
+
availability: "available",
|
|
207
|
+
})),
|
|
208
|
+
{
|
|
209
|
+
operation: "posts.publish",
|
|
210
|
+
platform: "youtube",
|
|
211
|
+
availability: "available",
|
|
212
|
+
formats: ["video"],
|
|
213
|
+
requiredScopes: ["https://www.googleapis.com/auth/youtube.upload"],
|
|
214
|
+
notes: "Unaudited projects may be restricted to private visibility. Upload completion and video processing are separate.",
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
operation: "posts.schedule",
|
|
218
|
+
platform: "youtube",
|
|
219
|
+
availability: "available",
|
|
220
|
+
requiredScopes: ["https://www.googleapis.com/auth/youtube.upload"],
|
|
221
|
+
notes: "Scheduled videos are uploaded private with a future ISO publishAt timestamp.",
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
operation: "posts.removeFromPlatform",
|
|
225
|
+
platform: "youtube",
|
|
226
|
+
availability: "available",
|
|
227
|
+
requiredScopes: ["https://www.googleapis.com/auth/youtube"],
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
operation: "playlists.read",
|
|
231
|
+
platform: "youtube",
|
|
232
|
+
availability: "available",
|
|
233
|
+
requiredScopes: ["https://www.googleapis.com/auth/youtube.readonly"],
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
operation: "playlists.write",
|
|
237
|
+
platform: "youtube",
|
|
238
|
+
availability: "available",
|
|
239
|
+
requiredScopes: ["https://www.googleapis.com/auth/youtube"],
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
operation: "thumbnails.write",
|
|
243
|
+
platform: "youtube",
|
|
244
|
+
availability: "available",
|
|
245
|
+
requiredScopes: ["https://www.googleapis.com/auth/youtube"],
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
operation: "videos.update",
|
|
249
|
+
platform: "youtube",
|
|
250
|
+
availability: "available",
|
|
251
|
+
requiredScopes: ["https://www.googleapis.com/auth/youtube"],
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
operation: "posts.update",
|
|
255
|
+
platform: "youtube",
|
|
256
|
+
availability: "available",
|
|
257
|
+
requiredScopes: ["https://www.googleapis.com/auth/youtube"],
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
operation: "videos.delete",
|
|
261
|
+
platform: "youtube",
|
|
262
|
+
availability: "available",
|
|
263
|
+
requiredScopes: ["https://www.googleapis.com/auth/youtube"],
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
operation: "posts.delete",
|
|
267
|
+
platform: "youtube",
|
|
268
|
+
availability: "available",
|
|
269
|
+
requiredScopes: ["https://www.googleapis.com/auth/youtube"],
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
operation: "videos.rate",
|
|
273
|
+
platform: "youtube",
|
|
274
|
+
availability: "available",
|
|
275
|
+
requiredScopes: ["https://www.googleapis.com/auth/youtube.force-ssl"],
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
operation: "captions.read",
|
|
279
|
+
platform: "youtube",
|
|
280
|
+
availability: "available",
|
|
281
|
+
requiredScopes: ["https://www.googleapis.com/auth/youtube.force-ssl"],
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
operation: "captions.write",
|
|
285
|
+
platform: "youtube",
|
|
286
|
+
availability: "available",
|
|
287
|
+
requiredScopes: ["https://www.googleapis.com/auth/youtube.force-ssl"],
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
operation: "subscriptions.read",
|
|
291
|
+
platform: "youtube",
|
|
292
|
+
availability: "available",
|
|
293
|
+
requiredScopes: ["https://www.googleapis.com/auth/youtube.readonly"],
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
operation: "subscriptions.write",
|
|
297
|
+
platform: "youtube",
|
|
298
|
+
availability: "available",
|
|
299
|
+
requiredScopes: ["https://www.googleapis.com/auth/youtube"],
|
|
300
|
+
},
|
|
301
|
+
{ operation: "search.keyword", platform: "youtube", availability: "available" },
|
|
302
|
+
{
|
|
303
|
+
operation: "search.posts",
|
|
304
|
+
platform: "youtube",
|
|
305
|
+
availability: "available",
|
|
306
|
+
requiredScopes: ["https://www.googleapis.com/auth/youtube.readonly"],
|
|
307
|
+
notes: "Uses search.list with type=video. Each request costs 100 quota units.",
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
operation: "comments.moderate",
|
|
311
|
+
platform: "youtube",
|
|
312
|
+
availability: "available",
|
|
313
|
+
requiredScopes: ["https://www.googleapis.com/auth/youtube.force-ssl"],
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
operation: "analytics.youtube.read",
|
|
317
|
+
platform: "youtube",
|
|
318
|
+
availability: "available",
|
|
319
|
+
requiredScopes: ["https://www.googleapis.com/auth/yt-analytics.readonly"],
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
operation: "analytics.report.read",
|
|
323
|
+
platform: "youtube",
|
|
324
|
+
availability: "available",
|
|
325
|
+
requiredScopes: ["https://www.googleapis.com/auth/yt-analytics.readonly"],
|
|
326
|
+
notes: "Reports require an explicit bounded date range and provider-supported metrics.",
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
operation: "live.broadcasts",
|
|
330
|
+
platform: "youtube",
|
|
331
|
+
availability: "available",
|
|
332
|
+
requiredScopes: ["https://www.googleapis.com/auth/youtube"],
|
|
333
|
+
notes: "The channel must have live streaming enabled in YouTube Studio. Use native access: liveBroadcasts.",
|
|
334
|
+
},
|
|
335
|
+
],
|
|
336
|
+
},
|
|
337
|
+
accounts: {
|
|
338
|
+
async list(_input, context) {
|
|
339
|
+
return { items: [await accountInfo(context)] };
|
|
340
|
+
},
|
|
341
|
+
async get(ref, context) {
|
|
342
|
+
authorize(ref, context);
|
|
343
|
+
return accountInfo(context);
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
posts: {
|
|
347
|
+
async list(account, input, context) {
|
|
348
|
+
authorize(account, context);
|
|
349
|
+
const limit = input.limit ?? 25;
|
|
350
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 50 || input.cursor === "")
|
|
351
|
+
throw new SocialError({
|
|
352
|
+
code: "invalid_input",
|
|
353
|
+
operation: "posts.read",
|
|
354
|
+
message: "YouTube page size must be from 1 to 50 and page tokens must be nonempty.",
|
|
355
|
+
});
|
|
356
|
+
const channel = object(await request("/youtube/v3/channels", context, undefined, {
|
|
357
|
+
id: options.auth.channelId,
|
|
358
|
+
part: "contentDetails",
|
|
359
|
+
}));
|
|
360
|
+
const row = array(channel["items"])
|
|
361
|
+
.map(object)
|
|
362
|
+
.find((item) => item["id"] === options.auth.channelId);
|
|
363
|
+
if (!row)
|
|
364
|
+
throw new SocialError({
|
|
365
|
+
code: "unauthorized",
|
|
366
|
+
operation: "posts.list",
|
|
367
|
+
message: "Configured channel was not returned.",
|
|
368
|
+
});
|
|
369
|
+
const details = object(row["contentDetails"]), related = object(details["relatedPlaylists"]), uploads = string(related["uploads"]);
|
|
370
|
+
const page = object(await request("/youtube/v3/playlistItems", context, undefined, {
|
|
371
|
+
playlistId: uploads,
|
|
372
|
+
part: "snippet,contentDetails",
|
|
373
|
+
maxResults: String(limit),
|
|
374
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
375
|
+
...(input.cursor ? { pageToken: input.cursor } : {}),
|
|
376
|
+
}));
|
|
377
|
+
return {
|
|
378
|
+
items: array(page["items"]).map((value) => {
|
|
379
|
+
const item = object(value), snippet = object(item["snippet"]), content = object(item["contentDetails"]);
|
|
380
|
+
if (snippet["channelId"] !== options.auth.channelId ||
|
|
381
|
+
(snippet["videoOwnerChannelId"] !== undefined &&
|
|
382
|
+
snippet["videoOwnerChannelId"] !== options.auth.channelId))
|
|
383
|
+
throw new SocialError({
|
|
384
|
+
code: "unauthorized",
|
|
385
|
+
operation: "posts.read",
|
|
386
|
+
message: "YouTube returned a video from another channel.",
|
|
387
|
+
});
|
|
388
|
+
return {
|
|
389
|
+
id: string(content["videoId"]),
|
|
390
|
+
snippet: publicFields(snippet, [
|
|
391
|
+
"channelId",
|
|
392
|
+
"title",
|
|
393
|
+
"description",
|
|
394
|
+
"publishedAt",
|
|
395
|
+
"videoOwnerChannelId",
|
|
396
|
+
]),
|
|
397
|
+
contentDetails: publicFields(content, ["videoId", "videoPublishedAt"]),
|
|
398
|
+
};
|
|
399
|
+
}),
|
|
400
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
401
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
|
|
402
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- provider payload is validated at this adapter boundary.
|
|
403
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated external boundary or fixture contract.
|
|
404
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated external boundary or fixture contract.
|
|
405
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated external boundary or fixture contract.
|
|
406
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated external boundary or fixture contract.
|
|
407
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated external boundary or fixture contract.
|
|
408
|
+
...(typeof page["nextPageToken"] === "string"
|
|
409
|
+
? { nextCursor: page["nextPageToken"] }
|
|
410
|
+
: {}),
|
|
411
|
+
};
|
|
412
|
+
},
|
|
413
|
+
prepareTarget(target) {
|
|
414
|
+
const issues = [];
|
|
415
|
+
const fail = (code, message) => issues.push({ code, message, severity: "error", targetIndex: target.targetIndex });
|
|
416
|
+
if (target.account.platform !== "youtube" ||
|
|
417
|
+
target.account.accountId !== options.auth.channelId)
|
|
418
|
+
fail("youtube.channel", "Select the configured YouTube channel.");
|
|
419
|
+
const media = target.content.media;
|
|
420
|
+
if (media?.length !== 1 || media[0]?.kind !== "video")
|
|
421
|
+
fail("youtube.video", "YouTube requires exactly one video; text-only publication is unavailable.");
|
|
422
|
+
const item = media?.[0];
|
|
423
|
+
if (item) {
|
|
424
|
+
if (item.source.kind !== "blob" && item.source.kind !== "stream")
|
|
425
|
+
fail("youtube.source", "Provide a Blob or replayable stream for resumable upload.");
|
|
426
|
+
if (!item.mimeType?.startsWith("video/"))
|
|
427
|
+
fail("youtube.mime", "Declare the actual video MIME type.");
|
|
428
|
+
if (item.source.kind !== "blob" && !item.byteSize)
|
|
429
|
+
fail("youtube.size", "Streaming upload requires its exact byte size.");
|
|
430
|
+
}
|
|
431
|
+
const config = target.options === undefined ? {} : object(target.options);
|
|
432
|
+
if (
|
|
433
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
|
|
434
|
+
typeof config["title"] !== "string" ||
|
|
435
|
+
!config["title"] ||
|
|
436
|
+
[...config["title"]].length > 100 ||
|
|
437
|
+
/[<>]/u.test(config["title"]))
|
|
438
|
+
fail("youtube.title", "Select a title of 1 to 100 characters.");
|
|
439
|
+
if (!["public", "unlisted", "private"].includes(String(config["visibility"])))
|
|
440
|
+
fail("youtube.visibility", "Explicit visibility is required.");
|
|
441
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
|
|
442
|
+
if (typeof config["madeForKids"] !== "boolean")
|
|
443
|
+
fail("youtube.audience", "Explicit made-for-kids declaration is required.");
|
|
444
|
+
if (target.content.text !== undefined &&
|
|
445
|
+
new TextEncoder().encode(target.content.text).byteLength > 5000)
|
|
446
|
+
fail("youtube.description", "Description must be at most 5000 UTF-8 bytes.");
|
|
447
|
+
if (target.schedule &&
|
|
448
|
+
(!Number.isFinite(Date.parse(target.schedule.at)) ||
|
|
449
|
+
Date.parse(target.schedule.at) <= (options.clock?.() ?? new Date()).getTime()))
|
|
450
|
+
fail("youtube.schedule", "Schedule time must be a valid timestamp in the future.");
|
|
451
|
+
if (target.replyTo)
|
|
452
|
+
fail("youtube.operation", "Use comments.reply for replies.");
|
|
453
|
+
return issues;
|
|
454
|
+
},
|
|
455
|
+
async publishTarget(target, context) {
|
|
456
|
+
authorize(target.account, context);
|
|
457
|
+
const media = target.content.media?.[0];
|
|
458
|
+
if (!media)
|
|
459
|
+
throw new SocialError({
|
|
460
|
+
code: "invalid_input",
|
|
461
|
+
operation: "posts.publish",
|
|
462
|
+
message: "Video required.",
|
|
463
|
+
});
|
|
464
|
+
const config = object(target.options);
|
|
465
|
+
const uploadOptions = {
|
|
466
|
+
timeoutMs: Math.max(remainingBudget(context), 15 * 60_000),
|
|
467
|
+
accessToken: options.auth.accessToken,
|
|
468
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
469
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
470
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
471
|
+
...(context.signal ? { signal: context.signal } : {}),
|
|
472
|
+
};
|
|
473
|
+
const size = media.byteSize ?? (media.source.kind === "blob" ? media.source.blob.size : 0);
|
|
474
|
+
const session = await beginYouTubeUpload({
|
|
475
|
+
channelId: options.auth.channelId,
|
|
476
|
+
size,
|
|
477
|
+
mimeType: string(media.mimeType),
|
|
478
|
+
metadata: {
|
|
479
|
+
snippet: { title: string(config["title"]), description: target.content.text ?? "" },
|
|
480
|
+
status: {
|
|
481
|
+
privacyStatus: string(config["visibility"]),
|
|
482
|
+
selfDeclaredMadeForKids: config["madeForKids"] === true,
|
|
483
|
+
...(target.schedule
|
|
484
|
+
? {
|
|
485
|
+
publishAt: new Date(target.schedule.at).toISOString(),
|
|
486
|
+
privacyStatus: "private",
|
|
487
|
+
}
|
|
488
|
+
: {}),
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
}, uploadOptions);
|
|
492
|
+
if (options.saveUploadSession)
|
|
493
|
+
await options.saveUploadSession(session);
|
|
494
|
+
const result = await sendYouTubeUpload(session, media, uploadOptions);
|
|
495
|
+
if (result.state === "incomplete")
|
|
496
|
+
return {
|
|
497
|
+
account: target.account,
|
|
498
|
+
targetIndex: target.targetIndex,
|
|
499
|
+
observedAt: now(),
|
|
500
|
+
state: "unknown",
|
|
501
|
+
reason: "ambiguous-submission",
|
|
502
|
+
diagnostic: "Upload is incomplete. Query the saved server-side session and explicitly resume confirmed bytes.",
|
|
503
|
+
};
|
|
504
|
+
return outcome(result.video, target);
|
|
505
|
+
},
|
|
506
|
+
async get(ref, context) {
|
|
507
|
+
const video = await get(ref, context);
|
|
508
|
+
return {
|
|
509
|
+
id: string(video["id"]),
|
|
510
|
+
snippet: publicFields(video["snippet"], [
|
|
511
|
+
"title",
|
|
512
|
+
"description",
|
|
513
|
+
"channelId",
|
|
514
|
+
"publishedAt",
|
|
515
|
+
]),
|
|
516
|
+
status: publicFields(video["status"], [
|
|
517
|
+
"privacyStatus",
|
|
518
|
+
"uploadStatus",
|
|
519
|
+
"selfDeclaredMadeForKids",
|
|
520
|
+
]),
|
|
521
|
+
};
|
|
522
|
+
},
|
|
523
|
+
async getDelivery(ref, context) {
|
|
524
|
+
const account = {
|
|
525
|
+
kind: "connected-account",
|
|
526
|
+
version: 1,
|
|
527
|
+
backend: ref.backend,
|
|
528
|
+
platform: "youtube",
|
|
529
|
+
accountId: ref.accountId,
|
|
530
|
+
};
|
|
531
|
+
const video = await get({ ...account, kind: "platform-post", postId: ref.deliveryId }, context);
|
|
532
|
+
return outcome(video, { account, targetIndex: 0 });
|
|
533
|
+
},
|
|
534
|
+
async removeFromPlatform(ref, context) {
|
|
535
|
+
authorize(ref, context);
|
|
536
|
+
if (!ref.postId)
|
|
537
|
+
throw new SocialError({
|
|
538
|
+
code: "invalid_input",
|
|
539
|
+
operation: "posts.removeFromPlatform",
|
|
540
|
+
message: "postId is required.",
|
|
541
|
+
});
|
|
542
|
+
await request("/youtube/v3/videos", context, undefined, { id: ref.postId }, "DELETE");
|
|
543
|
+
},
|
|
544
|
+
},
|
|
545
|
+
search: {
|
|
546
|
+
async posts(account, input, context) {
|
|
547
|
+
authorize(account, context);
|
|
548
|
+
const limit = input.limit ?? 25;
|
|
549
|
+
if (input.scope !== undefined && input.scope !== "recent")
|
|
550
|
+
throw new SocialError({
|
|
551
|
+
code: "invalid_input",
|
|
552
|
+
operation: "search.posts",
|
|
553
|
+
message: "YouTube search supports only the recent scope.",
|
|
554
|
+
});
|
|
555
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 50)
|
|
556
|
+
throw new SocialError({
|
|
557
|
+
code: "invalid_input",
|
|
558
|
+
operation: "search.posts",
|
|
559
|
+
message: "YouTube search limit must be between 1 and 50.",
|
|
560
|
+
});
|
|
561
|
+
const page = object(await request("/youtube/v3/search", context, undefined, {
|
|
562
|
+
part: "snippet",
|
|
563
|
+
q: input.query,
|
|
564
|
+
type: "video",
|
|
565
|
+
maxResults: String(limit),
|
|
566
|
+
...(input.cursor ? { pageToken: input.cursor } : {}),
|
|
567
|
+
...(input.startTime ? { publishedAfter: input.startTime } : {}),
|
|
568
|
+
...(input.endTime ? { publishedBefore: input.endTime } : {}),
|
|
569
|
+
}));
|
|
570
|
+
const nextCursor = optionalString(page["nextPageToken"]);
|
|
571
|
+
return {
|
|
572
|
+
items: array(page["items"]).map((value) => object(value)),
|
|
573
|
+
...(nextCursor ? { nextCursor } : {}),
|
|
574
|
+
};
|
|
575
|
+
},
|
|
576
|
+
},
|
|
577
|
+
analytics: {
|
|
578
|
+
async getPostMetrics(ref, context) {
|
|
579
|
+
const video = await get(ref, context);
|
|
580
|
+
if (video["statistics"] === undefined)
|
|
581
|
+
return [];
|
|
582
|
+
const values = object(video["statistics"]);
|
|
583
|
+
const metrics = [];
|
|
584
|
+
for (const name of ["viewCount", "likeCount", "commentCount"]) {
|
|
585
|
+
const raw = values[name];
|
|
586
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
|
|
587
|
+
if (typeof raw !== "string" || !/^\d+$/.test(raw))
|
|
588
|
+
continue;
|
|
589
|
+
const value = Number(raw);
|
|
590
|
+
if (!Number.isSafeInteger(value))
|
|
591
|
+
continue;
|
|
592
|
+
metrics.push({
|
|
593
|
+
name,
|
|
594
|
+
value,
|
|
595
|
+
unit: "count",
|
|
596
|
+
period: "lifetime",
|
|
597
|
+
fetchedAt: now(),
|
|
598
|
+
freshness: "unknown",
|
|
599
|
+
source: "youtube-data-v3",
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
return metrics;
|
|
603
|
+
},
|
|
604
|
+
async getAccountMetrics(accountRef, context) {
|
|
605
|
+
// https://developers.google.com/youtube/v3/docs/channels/list
|
|
606
|
+
authorize(accountRef, context);
|
|
607
|
+
const result = object(await request("/youtube/v3/channels", context, undefined, {
|
|
608
|
+
id: options.auth.channelId,
|
|
609
|
+
part: "statistics,snippet",
|
|
610
|
+
}));
|
|
611
|
+
const channel = array(result["items"])
|
|
612
|
+
.map(object)
|
|
613
|
+
.find((item) => item["id"] === options.auth.channelId);
|
|
614
|
+
if (!channel)
|
|
615
|
+
throw new SocialError({
|
|
616
|
+
code: "unauthorized",
|
|
617
|
+
operation: "analytics.account.read",
|
|
618
|
+
message: "Configured channel was not returned for this authorization.",
|
|
619
|
+
});
|
|
620
|
+
const stats = object(channel["statistics"]);
|
|
621
|
+
const fetchedAt = now();
|
|
622
|
+
return ["viewCount", "subscriberCount", "videoCount"].flatMap((name) => {
|
|
623
|
+
if (name === "subscriberCount" && stats["hiddenSubscriberCount"] === true)
|
|
624
|
+
return [];
|
|
625
|
+
const raw = stats[name];
|
|
626
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
|
|
627
|
+
return typeof raw === "string" && /^\d+$/.test(raw) && Number.isSafeInteger(Number(raw))
|
|
628
|
+
? [
|
|
629
|
+
{
|
|
630
|
+
name,
|
|
631
|
+
value: Number(raw),
|
|
632
|
+
unit: "count",
|
|
633
|
+
period: "lifetime",
|
|
634
|
+
fetchedAt,
|
|
635
|
+
freshness: "unknown",
|
|
636
|
+
source: "youtube-data-v3:channels.statistics",
|
|
637
|
+
},
|
|
638
|
+
]
|
|
639
|
+
: [];
|
|
640
|
+
});
|
|
641
|
+
},
|
|
642
|
+
async getReport(accountRef, query, context) {
|
|
643
|
+
authorize(accountRef, context);
|
|
644
|
+
const date = /^\d{4}-\d{2}-\d{2}$/;
|
|
645
|
+
if (!date.test(query.from) ||
|
|
646
|
+
!date.test(query.to) ||
|
|
647
|
+
query.from > query.to ||
|
|
648
|
+
query.metrics.length === 0 ||
|
|
649
|
+
query.metrics.some((metric) => !/^[A-Za-z][A-Za-z0-9_.-]*$/.test(metric)) ||
|
|
650
|
+
query.dimensions?.some((dimension) => !/^[A-Za-z][A-Za-z0-9_.-]*$/.test(dimension)))
|
|
651
|
+
throw new SocialError({
|
|
652
|
+
code: "invalid_input",
|
|
653
|
+
operation: "analytics.report.read",
|
|
654
|
+
message: "YouTube reports require YYYY-MM-DD dates (from <= to) and provider-safe metric names.",
|
|
655
|
+
});
|
|
656
|
+
const result = object(await analyticsRequest("/v2/reports", context, undefined, {
|
|
657
|
+
ids: `channel==${options.auth.channelId}`,
|
|
658
|
+
startDate: query.from,
|
|
659
|
+
endDate: query.to,
|
|
660
|
+
metrics: query.metrics.join(","),
|
|
661
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
662
|
+
...(query.dimensions && query.dimensions.length > 0
|
|
663
|
+
? { dimensions: query.dimensions.join(",") }
|
|
664
|
+
: {}),
|
|
665
|
+
}));
|
|
666
|
+
const headers = array(result["columnHeaders"]).map((value) => {
|
|
667
|
+
const header = object(value);
|
|
668
|
+
return {
|
|
669
|
+
name: string(header["name"]),
|
|
670
|
+
type: optionalString(header["columnType"]) ?? "METRIC",
|
|
671
|
+
};
|
|
672
|
+
});
|
|
673
|
+
const rows = [];
|
|
674
|
+
// Analytics omits `rows` entirely when the requested period has no data.
|
|
675
|
+
for (const value of Array.isArray(result["rows"]) ? result["rows"] : []) {
|
|
676
|
+
const cells = array(value);
|
|
677
|
+
const dimensions = Object.fromEntries(headers.flatMap((header, index) => {
|
|
678
|
+
if (header.type !== "DIMENSION")
|
|
679
|
+
return [];
|
|
680
|
+
const raw = cells[index];
|
|
681
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
|
|
682
|
+
return typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean"
|
|
683
|
+
? [[header.name, raw]]
|
|
684
|
+
: [];
|
|
685
|
+
}));
|
|
686
|
+
const metrics = {};
|
|
687
|
+
headers.forEach((header, index) => {
|
|
688
|
+
if (header.type === "DIMENSION")
|
|
689
|
+
return;
|
|
690
|
+
const raw = cells[index];
|
|
691
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- validated boundary or fixture contract.
|
|
692
|
+
const parsed = typeof raw === "number"
|
|
693
|
+
? raw
|
|
694
|
+
: typeof raw === "string" && raw.trim() !== ""
|
|
695
|
+
? Number(raw)
|
|
696
|
+
: undefined;
|
|
697
|
+
if (parsed !== undefined && Number.isFinite(parsed))
|
|
698
|
+
metrics[header.name] = parsed;
|
|
699
|
+
});
|
|
700
|
+
rows.push({ dimensions, metrics });
|
|
701
|
+
}
|
|
702
|
+
return { query, rows, fetchedAt: now(), source: "youtube-analytics-v2" };
|
|
703
|
+
},
|
|
704
|
+
},
|
|
705
|
+
comments: {
|
|
706
|
+
async list(ref, input, context) {
|
|
707
|
+
authorize(ref, context);
|
|
708
|
+
const limit = input.limit ?? 25;
|
|
709
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)
|
|
710
|
+
throw new SocialError({
|
|
711
|
+
code: "invalid_input",
|
|
712
|
+
operation: "comments.read",
|
|
713
|
+
message: "YouTube comment page size must be between 1 and 100.",
|
|
714
|
+
});
|
|
715
|
+
const result = object(await request("/youtube/v3/commentThreads", context, undefined, {
|
|
716
|
+
part: "snippet",
|
|
717
|
+
videoId: ref.postId,
|
|
718
|
+
textFormat: "plainText",
|
|
719
|
+
maxResults: String(limit),
|
|
720
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
721
|
+
...(input.cursor === undefined ? {} : { pageToken: input.cursor }),
|
|
722
|
+
}));
|
|
723
|
+
const cursor = optionalString(result["nextPageToken"]);
|
|
724
|
+
return {
|
|
725
|
+
items: array(result["items"]).map((value) => {
|
|
726
|
+
const row = object(value);
|
|
727
|
+
const comment = object(object(row["snippet"])["topLevelComment"]);
|
|
728
|
+
return {
|
|
729
|
+
id: string(comment["id"]),
|
|
730
|
+
...publicFields(comment["snippet"], [
|
|
731
|
+
"textDisplay",
|
|
732
|
+
"authorDisplayName",
|
|
733
|
+
"publishedAt",
|
|
734
|
+
"likeCount",
|
|
735
|
+
]),
|
|
736
|
+
};
|
|
737
|
+
}),
|
|
738
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
739
|
+
...(cursor ? { nextCursor: cursor } : {}),
|
|
740
|
+
};
|
|
741
|
+
},
|
|
742
|
+
async reply(ref, content, context) {
|
|
743
|
+
authorize(ref, context);
|
|
744
|
+
const parentResult = object(await request("/youtube/v3/comments", context, undefined, {
|
|
745
|
+
id: ref.commentId,
|
|
746
|
+
part: "snippet",
|
|
747
|
+
}));
|
|
748
|
+
const parent = array(parentResult["items"]).map(object)[0];
|
|
749
|
+
const parentSnippet = parent === undefined ? undefined : object(parent["snippet"]);
|
|
750
|
+
if (parentSnippet === undefined || parentSnippet["videoId"] !== ref.postId)
|
|
751
|
+
throw new SocialError({
|
|
752
|
+
code: "unauthorized",
|
|
753
|
+
operation: "comments.write",
|
|
754
|
+
message: "YouTube comment does not belong to the supplied video.",
|
|
755
|
+
});
|
|
756
|
+
const result = object(await request("/youtube/v3/comments", context, { snippet: { parentId: ref.commentId, textOriginal: content.text } }, { part: "snippet" }));
|
|
757
|
+
return { ...ref, commentId: string(result["id"]) };
|
|
758
|
+
},
|
|
759
|
+
},
|
|
760
|
+
native: {
|
|
761
|
+
async resumeUpload(session, media, context) {
|
|
762
|
+
if (session.channelId !== options.auth.channelId)
|
|
763
|
+
throw new SocialError({
|
|
764
|
+
code: "unauthorized",
|
|
765
|
+
operation: "youtube.upload",
|
|
766
|
+
message: "Upload session belongs to another channel.",
|
|
767
|
+
});
|
|
768
|
+
const uploadOptions = {
|
|
769
|
+
timeoutMs: remainingBudget(context),
|
|
770
|
+
accessToken: options.auth.accessToken,
|
|
771
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
772
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
773
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
774
|
+
...(context.signal ? { signal: context.signal } : {}),
|
|
775
|
+
};
|
|
776
|
+
const confirmed = await queryYouTubeUpload(session, uploadOptions);
|
|
777
|
+
if (confirmed.state === "complete")
|
|
778
|
+
return confirmed;
|
|
779
|
+
return sendYouTubeUpload(session, media, uploadOptions, confirmed.nextByte);
|
|
780
|
+
},
|
|
781
|
+
async queryUpload(session, context) {
|
|
782
|
+
if (session.channelId !== options.auth.channelId)
|
|
783
|
+
throw new SocialError({
|
|
784
|
+
code: "unauthorized",
|
|
785
|
+
operation: "youtube.upload",
|
|
786
|
+
message: "Upload session belongs to another channel.",
|
|
787
|
+
});
|
|
788
|
+
return queryYouTubeUpload(session, {
|
|
789
|
+
accessToken: options.auth.accessToken,
|
|
790
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
791
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
792
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
793
|
+
...(context.signal ? { signal: context.signal } : {}),
|
|
794
|
+
});
|
|
795
|
+
},
|
|
796
|
+
async setThumbnail({ videoId, thumbnail, context }) {
|
|
797
|
+
nativeAuthorize(context);
|
|
798
|
+
return binaryJson("/upload/youtube/v3/thumbnails/set", context, mediaBlob(thumbnail), { videoId, uploadType: "media" }, "POST", thumbnail.mimeType ?? "application/octet-stream");
|
|
799
|
+
},
|
|
800
|
+
async captions({ action, videoId, captionId, caption, body, context }) {
|
|
801
|
+
nativeAuthorize(context);
|
|
802
|
+
if (action === "download") {
|
|
803
|
+
if (!captionId)
|
|
804
|
+
throw new SocialError({
|
|
805
|
+
code: "invalid_input",
|
|
806
|
+
operation: "captions.download",
|
|
807
|
+
message: "captionId is required.",
|
|
808
|
+
});
|
|
809
|
+
const url = new URL(`https://www.googleapis.com/youtube/v3/captions/${encodeURIComponent(captionId)}`);
|
|
810
|
+
url.searchParams.set("tfmt", "vtt");
|
|
811
|
+
const response = await (options.fetch ?? globalThis.fetch)(url, {
|
|
812
|
+
headers: { Authorization: `Bearer ${options.auth.accessToken}` },
|
|
813
|
+
redirect: "error",
|
|
814
|
+
...(context.signal ? { signal: context.signal } : {}),
|
|
815
|
+
});
|
|
816
|
+
if (!response.ok)
|
|
817
|
+
throw new SocialError({
|
|
818
|
+
code: "upstream_failure",
|
|
819
|
+
operation: "captions.download",
|
|
820
|
+
message: `YouTube returned HTTP ${response.status}.`,
|
|
821
|
+
upstreamStatus: response.status,
|
|
822
|
+
retryDisposition: { kind: "never" },
|
|
823
|
+
});
|
|
824
|
+
return response.blob();
|
|
825
|
+
}
|
|
826
|
+
if (action === "insert" || action === "update") {
|
|
827
|
+
if (!body || (action === "insert" && !caption))
|
|
828
|
+
throw new SocialError({
|
|
829
|
+
code: "invalid_input",
|
|
830
|
+
operation: `captions.${action}`,
|
|
831
|
+
message: "Caption metadata and media are required for insert.",
|
|
832
|
+
});
|
|
833
|
+
const snippet = object(body["snippet"] ?? {});
|
|
834
|
+
if (action === "insert" && (!videoId || (snippet["videoId"] ?? videoId) !== videoId))
|
|
835
|
+
throw new SocialError({
|
|
836
|
+
code: "invalid_input",
|
|
837
|
+
operation: "captions.insert",
|
|
838
|
+
message: "videoId is required and must match body.snippet.videoId.",
|
|
839
|
+
});
|
|
840
|
+
const metadata = action === "update"
|
|
841
|
+
? { ...body, id: captionId ?? body["id"] ?? null }
|
|
842
|
+
: { ...body, snippet: { ...snippet, videoId: videoId ?? null } };
|
|
843
|
+
if (action === "update" && typeof metadata["id"] !== "string")
|
|
844
|
+
throw new SocialError({
|
|
845
|
+
code: "invalid_input",
|
|
846
|
+
operation: "captions.update",
|
|
847
|
+
message: "captionId or body.id is required.",
|
|
848
|
+
});
|
|
849
|
+
if (action === "update" && !caption)
|
|
850
|
+
return binaryJson("/youtube/v3/captions", context, JSON.stringify(metadata), { part: "snippet" }, "PUT", "application/json");
|
|
851
|
+
if (!caption)
|
|
852
|
+
throw new SocialError({
|
|
853
|
+
code: "invalid_input",
|
|
854
|
+
operation: "captions.insert",
|
|
855
|
+
message: "Caption media is required.",
|
|
856
|
+
});
|
|
857
|
+
const boundary = `youtube-caption-${crypto.randomUUID()}`;
|
|
858
|
+
const media = mediaBlob(caption);
|
|
859
|
+
const filename = (caption.filename ?? "captions.vtt").replace(/[\r\n]/gu, "");
|
|
860
|
+
const encoded = new Blob([
|
|
861
|
+
`--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${JSON.stringify(metadata)}\r\n`,
|
|
862
|
+
`--${boundary}\r\nContent-Type: ${caption.mimeType ?? "text/vtt"}\r\nContent-Disposition: attachment; filename="${filename}"\r\n\r\n`,
|
|
863
|
+
media,
|
|
864
|
+
`\r\n--${boundary}--\r\n`,
|
|
865
|
+
]);
|
|
866
|
+
return binaryJson("/upload/youtube/v3/captions", context, encoded, { uploadType: "multipart", part: "snippet" }, action === "update" ? "PUT" : "POST", `multipart/related; boundary=${boundary}`);
|
|
867
|
+
}
|
|
868
|
+
if (action === "delete") {
|
|
869
|
+
if (!captionId)
|
|
870
|
+
throw new SocialError({
|
|
871
|
+
code: "invalid_input",
|
|
872
|
+
operation: "captions.delete",
|
|
873
|
+
message: "captionId is required.",
|
|
874
|
+
});
|
|
875
|
+
await request("/youtube/v3/captions", context, undefined, { id: captionId ?? "" }, "DELETE");
|
|
876
|
+
return {};
|
|
877
|
+
}
|
|
878
|
+
return object(await request("/youtube/v3/captions", context, undefined, { part: "snippet", videoId }, "GET"));
|
|
879
|
+
},
|
|
880
|
+
async playlists({ action, playlistId, channelId, mine, pageToken, maxResults, body, context, }) {
|
|
881
|
+
nativeAuthorize(context);
|
|
882
|
+
const method = action === "list"
|
|
883
|
+
? "GET"
|
|
884
|
+
: action === "delete"
|
|
885
|
+
? "DELETE"
|
|
886
|
+
: action === "update"
|
|
887
|
+
? "PUT"
|
|
888
|
+
: "POST";
|
|
889
|
+
if ((action === "delete" || action === "update") && !playlistId)
|
|
890
|
+
throw new SocialError({
|
|
891
|
+
code: "invalid_input",
|
|
892
|
+
operation: `playlists.${action}`,
|
|
893
|
+
message: "playlistId is required.",
|
|
894
|
+
});
|
|
895
|
+
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
|
|
896
|
+
if (action === "delete") {
|
|
897
|
+
await request("/youtube/v3/playlists", context, undefined, { id: playlistId ?? "" }, method);
|
|
898
|
+
return {};
|
|
899
|
+
}
|
|
900
|
+
return object(await request("/youtube/v3/playlists", context, body, {
|
|
901
|
+
part: "snippet,status,contentDetails",
|
|
902
|
+
...(playlistId ? { id: playlistId } : {}),
|
|
903
|
+
...(channelId ? { channelId } : {}),
|
|
904
|
+
...(action === "list" && !playlistId && !channelId
|
|
905
|
+
? { mine: "true" }
|
|
906
|
+
: mine
|
|
907
|
+
? { mine: "true" }
|
|
908
|
+
: {}),
|
|
909
|
+
...(pageToken ? { pageToken } : {}),
|
|
910
|
+
...(maxResults ? { maxResults: String(maxResults) } : {}),
|
|
911
|
+
}, method));
|
|
912
|
+
},
|
|
913
|
+
async playlistItems({ action, playlistId, playlistItemId, body, pageToken, context }) {
|
|
914
|
+
nativeAuthorize(context);
|
|
915
|
+
const method = action === "list"
|
|
916
|
+
? "GET"
|
|
917
|
+
: action === "delete"
|
|
918
|
+
? "DELETE"
|
|
919
|
+
: action === "update"
|
|
920
|
+
? "PUT"
|
|
921
|
+
: "POST";
|
|
922
|
+
if (action === "delete") {
|
|
923
|
+
if (!playlistItemId)
|
|
924
|
+
throw new SocialError({
|
|
925
|
+
code: "invalid_input",
|
|
926
|
+
operation: "playlistItems.delete",
|
|
927
|
+
message: "playlistItemId is required.",
|
|
928
|
+
});
|
|
929
|
+
await request("/youtube/v3/playlistItems", context, undefined, { id: playlistItemId }, method);
|
|
930
|
+
return {};
|
|
931
|
+
}
|
|
932
|
+
return object(await request("/youtube/v3/playlistItems", context, body, {
|
|
933
|
+
part: "snippet,contentDetails",
|
|
934
|
+
...(playlistId ? { playlistId } : {}),
|
|
935
|
+
...(playlistItemId ? { id: playlistItemId } : {}),
|
|
936
|
+
...(pageToken ? { pageToken } : {}),
|
|
937
|
+
}, method));
|
|
938
|
+
},
|
|
939
|
+
async updateVideo({ videoId, body, context }) {
|
|
940
|
+
nativeAuthorize(context);
|
|
941
|
+
if (!videoId)
|
|
942
|
+
throw new SocialError({
|
|
943
|
+
code: "invalid_input",
|
|
944
|
+
operation: "videos.update",
|
|
945
|
+
message: "videoId is required.",
|
|
946
|
+
});
|
|
947
|
+
const existing = await get({
|
|
948
|
+
kind: "platform-post",
|
|
949
|
+
version: 1,
|
|
950
|
+
backend: context.backendInstance,
|
|
951
|
+
platform: "youtube",
|
|
952
|
+
accountId: options.auth.channelId,
|
|
953
|
+
postId: videoId,
|
|
954
|
+
}, context);
|
|
955
|
+
const merged = {
|
|
956
|
+
...existing,
|
|
957
|
+
...body,
|
|
958
|
+
id: videoId,
|
|
959
|
+
snippet: { ...object(existing["snippet"]), ...object(body["snippet"] ?? {}) },
|
|
960
|
+
status: { ...object(existing["status"] ?? {}), ...object(body["status"] ?? {}) },
|
|
961
|
+
};
|
|
962
|
+
return object(await request("/youtube/v3/videos", context, merged, { part: "snippet,status" }, "PUT"));
|
|
963
|
+
},
|
|
964
|
+
async deleteVideo({ videoId, context }) {
|
|
965
|
+
nativeAuthorize(context);
|
|
966
|
+
if (!videoId)
|
|
967
|
+
throw new SocialError({
|
|
968
|
+
code: "invalid_input",
|
|
969
|
+
operation: "videos.delete",
|
|
970
|
+
message: "videoId is required.",
|
|
971
|
+
});
|
|
972
|
+
await request("/youtube/v3/videos", context, undefined, { id: videoId }, "DELETE");
|
|
973
|
+
},
|
|
974
|
+
async rateVideo({ videoId, rating, context }) {
|
|
975
|
+
nativeAuthorize(context);
|
|
976
|
+
await request("/youtube/v3/videos/rate", context, undefined, { id: videoId, rating }, "POST");
|
|
977
|
+
},
|
|
978
|
+
async getRating({ videoIds, context }) {
|
|
979
|
+
nativeAuthorize(context);
|
|
980
|
+
return object(await request("/youtube/v3/videos/getRating", context, undefined, {
|
|
981
|
+
id: videoIds.join(","),
|
|
982
|
+
}));
|
|
983
|
+
},
|
|
984
|
+
async subscriptions({ action, subscriptionId, channelId, pageToken, context }) {
|
|
985
|
+
nativeAuthorize(context);
|
|
986
|
+
const method = action === "list" ? "GET" : action === "delete" ? "DELETE" : "POST";
|
|
987
|
+
if (action === "delete" && !subscriptionId)
|
|
988
|
+
throw new SocialError({
|
|
989
|
+
code: "invalid_input",
|
|
990
|
+
operation: "subscriptions.delete",
|
|
991
|
+
message: "subscriptionId is required.",
|
|
992
|
+
});
|
|
993
|
+
const insertBody = channelId
|
|
994
|
+
? {
|
|
995
|
+
snippet: { resourceId: { kind: "youtube#channel", channelId } },
|
|
996
|
+
}
|
|
997
|
+
: undefined;
|
|
998
|
+
if (action === "delete") {
|
|
999
|
+
await request("/youtube/v3/subscriptions", context, undefined, { id: subscriptionId ?? "" }, method);
|
|
1000
|
+
return {};
|
|
1001
|
+
}
|
|
1002
|
+
return object(await request("/youtube/v3/subscriptions", context, insertBody, {
|
|
1003
|
+
part: "snippet,contentDetails",
|
|
1004
|
+
...(subscriptionId
|
|
1005
|
+
? { id: subscriptionId }
|
|
1006
|
+
: channelId
|
|
1007
|
+
? { channelId }
|
|
1008
|
+
: action === "list"
|
|
1009
|
+
? { mine: "true" }
|
|
1010
|
+
: {}),
|
|
1011
|
+
...(pageToken ? { pageToken } : {}),
|
|
1012
|
+
}, method));
|
|
1013
|
+
},
|
|
1014
|
+
async search({ q, type, channelId, order, publishedAfter, publishedBefore, pageToken, maxResults, context, }) {
|
|
1015
|
+
nativeAuthorize(context);
|
|
1016
|
+
return object(await request("/youtube/v3/search", context, undefined, {
|
|
1017
|
+
part: "snippet",
|
|
1018
|
+
q,
|
|
1019
|
+
...(type ? { type } : {}),
|
|
1020
|
+
...(channelId ? { channelId } : {}),
|
|
1021
|
+
...(order ? { order } : {}),
|
|
1022
|
+
...(publishedAfter ? { publishedAfter } : {}),
|
|
1023
|
+
...(publishedBefore ? { publishedBefore } : {}),
|
|
1024
|
+
...(pageToken ? { pageToken } : {}),
|
|
1025
|
+
...(maxResults ? { maxResults: String(maxResults) } : {}),
|
|
1026
|
+
}));
|
|
1027
|
+
},
|
|
1028
|
+
async commentsModeration({ action, commentId, moderationStatus, banAuthor, body, context }) {
|
|
1029
|
+
nativeAuthorize(context);
|
|
1030
|
+
if (!commentId)
|
|
1031
|
+
throw new SocialError({
|
|
1032
|
+
code: "invalid_input",
|
|
1033
|
+
operation: "comments.moderate",
|
|
1034
|
+
message: "commentId is required.",
|
|
1035
|
+
});
|
|
1036
|
+
if (action === "setModerationStatus") {
|
|
1037
|
+
await request("/youtube/v3/comments/setModerationStatus", context, undefined, {
|
|
1038
|
+
id: commentId,
|
|
1039
|
+
moderationStatus: moderationStatus ??
|
|
1040
|
+
(() => {
|
|
1041
|
+
throw new SocialError({
|
|
1042
|
+
code: "invalid_input",
|
|
1043
|
+
operation: "comments.moderate",
|
|
1044
|
+
message: "moderationStatus is required.",
|
|
1045
|
+
});
|
|
1046
|
+
})(),
|
|
1047
|
+
...(banAuthor ? { banAuthor: "true" } : {}),
|
|
1048
|
+
}, "POST");
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
if (action === "delete") {
|
|
1052
|
+
await request("/youtube/v3/comments", context, undefined, { id: commentId }, "DELETE");
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
if (!body || (body["id"] ?? commentId) !== commentId)
|
|
1056
|
+
throw new SocialError({
|
|
1057
|
+
code: "invalid_input",
|
|
1058
|
+
operation: "comments.update",
|
|
1059
|
+
message: "Provide a comment body whose id, if present, matches commentId.",
|
|
1060
|
+
});
|
|
1061
|
+
return object(await request("/youtube/v3/comments", context, { ...body, id: commentId }, { part: "snippet" }, "PUT"));
|
|
1062
|
+
},
|
|
1063
|
+
async heldComments({ pageToken, maxResults, context }) {
|
|
1064
|
+
nativeAuthorize(context);
|
|
1065
|
+
return object(await request("/youtube/v3/commentThreads", context, undefined, {
|
|
1066
|
+
part: "snippet",
|
|
1067
|
+
moderationStatus: "heldForReview",
|
|
1068
|
+
allThreadsRelatedToChannelId: options.auth.channelId,
|
|
1069
|
+
...(pageToken ? { pageToken } : {}),
|
|
1070
|
+
...(maxResults ? { maxResults: String(maxResults) } : {}),
|
|
1071
|
+
}));
|
|
1072
|
+
},
|
|
1073
|
+
async analytics({ query, context }) {
|
|
1074
|
+
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
|
|
1075
|
+
return object(await analyticsRequest("/v2/reports", context, undefined, query));
|
|
1076
|
+
},
|
|
1077
|
+
async liveBroadcasts({ action, body, id, broadcastStatus, context }) {
|
|
1078
|
+
if (action === "list")
|
|
1079
|
+
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
|
|
1080
|
+
return object(await request("/youtube/v3/liveBroadcasts", context, undefined, {
|
|
1081
|
+
part: "snippet,status",
|
|
1082
|
+
// oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread -- validated boundary or fixture contract.
|
|
1083
|
+
...(id ? { id } : { mine: "true" }),
|
|
1084
|
+
}));
|
|
1085
|
+
if (action === "transition") {
|
|
1086
|
+
if (!id || !broadcastStatus)
|
|
1087
|
+
throw new SocialError({
|
|
1088
|
+
code: "invalid_input",
|
|
1089
|
+
operation: "live.broadcasts",
|
|
1090
|
+
message: "id and broadcastStatus are required.",
|
|
1091
|
+
});
|
|
1092
|
+
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
|
|
1093
|
+
return object(await request("/youtube/v3/liveBroadcasts/transition", context, undefined, { part: "snippet,status", id, broadcastStatus }, "POST"));
|
|
1094
|
+
}
|
|
1095
|
+
// oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- validated boundary or fixture contract.
|
|
1096
|
+
return object(await request("/youtube/v3/liveBroadcasts", context, body, { part: "snippet,status" }, "POST"));
|
|
1097
|
+
},
|
|
1098
|
+
},
|
|
1099
|
+
});
|
|
1100
|
+
}
|