@mannyflussnpm/adapter-youtube 1.0.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,32 @@
1
+ [
2
+ {
3
+ "id": "act-001",
4
+ "snippet": {
5
+ "publishedAt": "2023-11-14T10:00:00.000Z",
6
+ "title": "How to Build a Data Vault in TypeScript",
7
+ "description": "A walkthrough of building a personal data vault from scratch",
8
+ "type": "upload",
9
+ "channelTitle": "Tech With Tim"
10
+ },
11
+ "contentDetails": {
12
+ "upload": {
13
+ "videoId": "dQw4w9WgXcQ"
14
+ }
15
+ }
16
+ },
17
+ {
18
+ "id": "act-002",
19
+ "snippet": {
20
+ "publishedAt": "2023-11-14T14:30:00.000Z",
21
+ "title": "The Future of Local-First Software",
22
+ "description": "Why local-first is the next big thing in software architecture",
23
+ "type": "upload",
24
+ "channelTitle": "Fireship"
25
+ },
26
+ "contentDetails": {
27
+ "upload": {
28
+ "videoId": "jNQXAC9IVRw"
29
+ }
30
+ }
31
+ }
32
+ ]
package/index.ts ADDED
@@ -0,0 +1,211 @@
1
+ /**
2
+ * youtube — Google YouTube adapter via OAuth 2.0.
3
+ *
4
+ * Reads the user's YouTube watch history using the watch history playlist
5
+ * (playlist ID "HL"). Emits `video_watched` envelopes with video title,
6
+ * channel, URL, and watch date.
7
+ *
8
+ * Cursor format: JSON with `pageToken` (for pagination) and `maxDate`
9
+ * (ISO string). During initial backfill, pageToken drives pagination in
10
+ * chunks of ~500. Once backfill is complete, only new watches (after
11
+ * maxDate) are pulled.
12
+ *
13
+ * Prerequisites:
14
+ * 1. Create a Google Cloud project, enable YouTube Data API v3
15
+ * 2. Create OAuth 2.0 Desktop client ID
16
+ * 3. Save credentials to ~/.personal-data-vault/google-credentials.json
17
+ * 4. First run opens browser for consent
18
+ *
19
+ * Falls back to synthetic fixture when credentials are absent (conformance).
20
+ */
21
+
22
+ import { readFileSync } from "node:fs";
23
+ import { fileURLToPath } from "node:url";
24
+ import { existsSync } from "node:fs";
25
+ import { google, youtube_v3 } from "googleapis";
26
+ import {
27
+ ENVELOPE_VERSION,
28
+ type Adapter,
29
+ type AdapterContext,
30
+ type Cursor,
31
+ type Envelope,
32
+ type PullResult,
33
+ } from "@mannyflussnpm/spec";
34
+ import { getAuthClient, SCOPES } from "@mannyflussnpm/adapter-google";
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Config
38
+ // ---------------------------------------------------------------------------
39
+
40
+ const FIXTURE_PATH = fileURLToPath(
41
+ new URL("./fixtures/activities.json", import.meta.url),
42
+ );
43
+
44
+ const PER_PAGE = 50;
45
+ const MAX_PER_PULL = 500;
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Cursor helpers
49
+ // ---------------------------------------------------------------------------
50
+
51
+ interface YoutubeCursor {
52
+ pageToken: string | null;
53
+ maxDate: string; // ISO
54
+ }
55
+
56
+ function parseCursor(cursor: Cursor): YoutubeCursor {
57
+ if (!cursor) return { pageToken: null, maxDate: "1970-01-01T00:00:00.000Z" };
58
+ try {
59
+ return JSON.parse(cursor) as YoutubeCursor;
60
+ } catch {
61
+ return { pageToken: null, maxDate: cursor };
62
+ }
63
+ }
64
+
65
+ function encodeCursor(c: YoutubeCursor): string {
66
+ return JSON.stringify(c);
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Adapter
71
+ // ---------------------------------------------------------------------------
72
+
73
+ const adapter: Adapter = {
74
+ name: "youtube",
75
+
76
+ async pull(cursor: Cursor, _ctx: AdapterContext): Promise<PullResult> {
77
+ // --- Fixture mode (no credentials, or forced) ---
78
+ const useFixture =
79
+ process.env.PDV_FIXTURE_MODE === "1" ||
80
+ !existsSync(
81
+ process.env.GOOGLE_CREDENTIALS_PATH ??
82
+ `${process.env.HOME}/.personal-data-vault/google-credentials.json`,
83
+ );
84
+
85
+ if (useFixture) {
86
+ let raw: string;
87
+ try {
88
+ raw = readFileSync(FIXTURE_PATH, "utf8");
89
+ } catch {
90
+ return { events: [], cursor, hasMore: false };
91
+ }
92
+ const activities = JSON.parse(raw) as youtube_v3.Schema$Activity[];
93
+ const cur = parseCursor(cursor);
94
+ const since = new Date(cur.maxDate);
95
+ const events: Envelope[] = [];
96
+ let maxDate = since;
97
+
98
+ for (const act of activities) {
99
+ const ts = new Date(act.snippet?.publishedAt ?? 0);
100
+ if (ts <= since) continue;
101
+
102
+ const videoId = act.contentDetails?.upload?.videoId;
103
+ events.push({
104
+ v: ENVELOPE_VERSION,
105
+ ts: ts.toISOString(),
106
+ source: adapter.name,
107
+ type: "video_watched",
108
+ id: act.id!,
109
+ payload: {
110
+ title: act.snippet?.title ?? "",
111
+ channel: act.snippet?.channelTitle ?? "",
112
+ videoId,
113
+ url: videoId ? `https://youtube.com/watch?v=${videoId}` : null,
114
+ },
115
+ });
116
+
117
+ if (ts > maxDate) maxDate = ts;
118
+ }
119
+
120
+ return {
121
+ events,
122
+ cursor: events.length > 0
123
+ ? encodeCursor({ pageToken: null, maxDate: maxDate.toISOString() })
124
+ : cursor,
125
+ hasMore: false,
126
+ };
127
+ }
128
+
129
+ // --- Live mode: activities feed ---
130
+ const auth = await getAuthClient("youtube", SCOPES.youtube);
131
+ const youtube = google.youtube({ version: "v3", auth });
132
+
133
+ const cur = parseCursor(cursor);
134
+ const since = new Date(cur.maxDate);
135
+ const events: Envelope[] = [];
136
+ let maxDate = since;
137
+ let pageToken: string | undefined = cur.pageToken ?? undefined;
138
+
139
+ let capped = false;
140
+
141
+ do {
142
+ const res = await youtube.activities.list({
143
+ mine: true,
144
+ part: ["snippet", "contentDetails"],
145
+ maxResults: PER_PAGE,
146
+ pageToken,
147
+ publishedAfter:
148
+ !cur.pageToken && cur.maxDate !== "1970-01-01T00:00:00.000Z"
149
+ ? since.toISOString()
150
+ : undefined,
151
+ });
152
+
153
+ const items = res.data.items ?? [];
154
+ for (const item of items) {
155
+ if (events.length >= MAX_PER_PULL) {
156
+ capped = true;
157
+ break;
158
+ }
159
+
160
+ const ts = new Date(item.snippet?.publishedAt ?? 0);
161
+ if (!cur.pageToken && ts <= since) continue;
162
+
163
+ const videoId =
164
+ item.contentDetails?.upload?.videoId ??
165
+ item.contentDetails?.playlistItem?.resourceId?.videoId ??
166
+ item.contentDetails?.subscription?.resourceId?.channelId;
167
+
168
+ const activityType = item.snippet?.type ?? "unknown";
169
+ events.push({
170
+ v: ENVELOPE_VERSION,
171
+ ts: ts.toISOString(),
172
+ source: adapter.name,
173
+ type: activityType === "upload" ? "video_watched" : "youtube_activity",
174
+ id: item.id!,
175
+ payload: {
176
+ activityType,
177
+ title: item.snippet?.title ?? "",
178
+ channel: item.snippet?.channelTitle ?? "",
179
+ videoId,
180
+ url: videoId
181
+ ? `https://youtube.com/watch?v=${videoId}`
182
+ : null,
183
+ },
184
+ });
185
+
186
+ if (ts > maxDate) maxDate = ts;
187
+ }
188
+
189
+ if (capped) {
190
+ pageToken = res.data.nextPageToken ?? undefined;
191
+ break;
192
+ }
193
+
194
+ pageToken = res.data.nextPageToken ?? undefined;
195
+ } while (pageToken);
196
+
197
+ return {
198
+ events,
199
+ cursor:
200
+ events.length > 0 || pageToken
201
+ ? encodeCursor({
202
+ pageToken: pageToken ?? null,
203
+ maxDate: maxDate.toISOString(),
204
+ })
205
+ : cursor,
206
+ hasMore: !!pageToken,
207
+ };
208
+ },
209
+ };
210
+
211
+ export default adapter;
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@mannyflussnpm/adapter-youtube",
3
+ "version": "1.0.0",
4
+ "description": "adapter for Personal Data Vault",
5
+ "type": "module",
6
+ "main": "./index.ts",
7
+ "types": "./index.ts",
8
+ "exports": {
9
+ ".": "./index.ts"
10
+ },
11
+ "pdv": {
12
+ "type": "adapter"
13
+ },
14
+ "peerDependencies": {
15
+ "@mannyflussnpm/spec": "^1.0.0"
16
+ },
17
+ "license": "MIT",
18
+ "dependencies": {
19
+ "@mannyflussnpm/adapter-google": "^1.0.0"
20
+ }
21
+ }