@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.
- package/fixtures/activities.json +32 -0
- package/index.ts +211 -0
- package/package.json +21 -0
|
@@ -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
|
+
}
|