@chronicle.app/youtube 0.3.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.
Files changed (33) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -0
  3. package/dist/auth/GoogleOAuthProvider.d.ts +14 -0
  4. package/dist/auth/GoogleOAuthProvider.js +15 -0
  5. package/dist/connectors/YouTubeBaseExtractor.d.ts +36 -0
  6. package/dist/connectors/YouTubeBaseExtractor.js +40 -0
  7. package/dist/connectors/YouTubeLikesExtractor.d.ts +17 -0
  8. package/dist/connectors/YouTubeLikesExtractor.js +54 -0
  9. package/dist/connectors/YouTubePlaylistsExtractor.d.ts +16 -0
  10. package/dist/connectors/YouTubePlaylistsExtractor.js +44 -0
  11. package/dist/connectors/YouTubeSubscriptionsExtractor.d.ts +10 -0
  12. package/dist/connectors/YouTubeSubscriptionsExtractor.js +27 -0
  13. package/dist/connectors/YouTubeTakeoutExtractor.d.ts +92 -0
  14. package/dist/connectors/YouTubeTakeoutExtractor.js +315 -0
  15. package/dist/connectors/YouTubeTakeoutTransformer.d.ts +27 -0
  16. package/dist/connectors/YouTubeTakeoutTransformer.js +260 -0
  17. package/dist/connectors/YouTubeTransformer.d.ts +15 -0
  18. package/dist/connectors/YouTubeTransformer.js +220 -0
  19. package/dist/connectors/YouTubeUploadsExtractor.d.ts +11 -0
  20. package/dist/connectors/YouTubeUploadsExtractor.js +50 -0
  21. package/dist/index.d.ts +9 -0
  22. package/dist/index.js +12 -0
  23. package/dist/takeout/csv.d.ts +4 -0
  24. package/dist/takeout/csv.js +12 -0
  25. package/dist/takeout/parseActivityHtml.d.ts +30 -0
  26. package/dist/takeout/parseActivityHtml.js +116 -0
  27. package/dist/utils/YouTubeProxy.d.ts +138 -0
  28. package/dist/utils/YouTubeProxy.js +138 -0
  29. package/dist/utils/time.d.ts +12 -0
  30. package/dist/utils/time.js +30 -0
  31. package/dist/utils/urls.d.ts +10 -0
  32. package/dist/utils/urls.js +15 -0
  33. package/package.json +92 -0
@@ -0,0 +1,315 @@
1
+ import { Extractor } from '@chronicle.app/etl';
2
+ import { readdir, readFile, stat } from 'node:fs/promises';
3
+ import { basename, join } from 'node:path';
4
+ import { z } from 'zod';
5
+ import { readTakeoutCsv } from '../takeout/csv.js';
6
+ import { parseActivityHtml, videoIdFromUrl } from '../takeout/parseActivityHtml.js';
7
+ import YouTubeTakeoutTransformer from './YouTubeTakeoutTransformer.js';
8
+ // Relative paths of the archive files this extractor reads.
9
+ const FILES = {
10
+ watch: 'history/watch-history.html',
11
+ search: 'history/search-history.html',
12
+ subscriptions: 'subscriptions/subscriptions.csv',
13
+ playlists: 'playlists/playlists.csv',
14
+ uploads: 'video metadata/videos.csv',
15
+ comments: 'comments/comments.csv',
16
+ channel: 'channels/channel.csv',
17
+ };
18
+ export class YouTubeTakeoutExtractor extends Extractor {
19
+ static source = 'youtube';
20
+ static description = 'Watch and search history and library from Takeout';
21
+ static delivery = 'export';
22
+ static strategy = 'takeout';
23
+ static recordTypes = [
24
+ 'watches',
25
+ 'searches',
26
+ 'subscriptions',
27
+ 'playlists',
28
+ 'playlist-items',
29
+ 'uploads',
30
+ 'comments',
31
+ ];
32
+ static default = false;
33
+ // A Takeout archive is a full re-read of account state: re-exporting
34
+ // re-reports the same rows with their latest values (current titles,
35
+ // current membership), so attributes are sighted at export time while
36
+ // actions keep their own occurrence timestamps.
37
+ static temporality = 'snapshot';
38
+ static defaultTransformer = YouTubeTakeoutTransformer;
39
+ // `input` is validated in setup() rather than by the schema: the CLI unions
40
+ // flags across a source's extractors, so a schema-required flag here would
41
+ // also gate the API extractors that share the youtube source.
42
+ static schema = Extractor.schema.extend({
43
+ input: z.string().optional().describe('Path to the Takeout "YouTube and YouTube Music" folder'),
44
+ });
45
+ archiveAsOf = null;
46
+ selfChannel = null;
47
+ inputPath(relative) {
48
+ const config = this.config;
49
+ if (!config.input) {
50
+ throw new Error('The takeout extractor needs --input: the path to the Takeout "YouTube and YouTube Music" folder');
51
+ }
52
+ return join(config.input, relative);
53
+ }
54
+ async fileExists(path) {
55
+ try {
56
+ await stat(path);
57
+ return true;
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
63
+ async setup() {
64
+ await super.setup();
65
+ // The archive is a static artifact: its sighting time is the export's,
66
+ // not this run's, so re-running over an unchanged tree is a byte-identical
67
+ // no-op. Newest mtime across the files read stands in for the export time.
68
+ const paths = [
69
+ ...Object.values(FILES).map(f => this.inputPath(f)),
70
+ ...(await this.playlistItemFiles()).map(([, path]) => path),
71
+ ];
72
+ let newest = 0;
73
+ for (const path of paths) {
74
+ try {
75
+ newest = Math.max(newest, (await stat(path)).mtimeMs);
76
+ }
77
+ catch {
78
+ // Missing sections are fine; each is skipped with a warning later.
79
+ }
80
+ }
81
+ if (newest > 0)
82
+ this.archiveAsOf = new Date(newest);
83
+ const channelPath = this.inputPath(FILES.channel);
84
+ if (await this.fileExists(channelPath)) {
85
+ const [row] = await readTakeoutCsv(channelPath);
86
+ const id = row?.['Channel ID'];
87
+ if (id) {
88
+ this.selfChannel = {
89
+ id,
90
+ title: row['Channel Title (Original)'] || undefined,
91
+ };
92
+ }
93
+ }
94
+ if (!this.selfChannel) {
95
+ this.logger.warn('channels/channel.csv missing or empty — the account owner falls back to the per-source singleton self');
96
+ }
97
+ }
98
+ asOfTime() {
99
+ return this.archiveAsOf ?? super.asOfTime();
100
+ }
101
+ inRange(time) {
102
+ const { since, until } = this.config;
103
+ if (since && time < since)
104
+ return false;
105
+ if (until && time > until)
106
+ return false;
107
+ return true;
108
+ }
109
+ /** `[playlist title, csv path]` for each per-playlist membership file. */
110
+ async playlistItemFiles() {
111
+ const dir = this.inputPath('playlists');
112
+ try {
113
+ const files = await readdir(dir);
114
+ return files
115
+ .filter(f => f.endsWith('-videos.csv'))
116
+ .map(f => [basename(f, '-videos.csv'), join(dir, f)]);
117
+ }
118
+ catch {
119
+ return [];
120
+ }
121
+ }
122
+ async *extract() {
123
+ let count = 0;
124
+ const self = this.selfChannel;
125
+ const emit = (recordType, data) => this.createRecord(data, { recordType, selfChannel: self });
126
+ for (const section of [
127
+ this.extractWatches(),
128
+ this.extractSearches(),
129
+ this.extractSubscriptions(),
130
+ this.extractPlaylists(),
131
+ this.extractUploads(),
132
+ this.extractComments(),
133
+ ]) {
134
+ for await (const [recordType, data] of section) {
135
+ if (this.shouldStopExtracting(count))
136
+ return;
137
+ yield emit(recordType, data);
138
+ count++;
139
+ }
140
+ }
141
+ }
142
+ async *extractWatches() {
143
+ const path = this.inputPath(FILES.watch);
144
+ if (!(await this.fileExists(path))) {
145
+ this.logger.warn(`No watch history at ${FILES.watch} — skipping`);
146
+ return;
147
+ }
148
+ const entries = parseActivityHtml(await readFile(path, 'utf-8'));
149
+ let ads = 0;
150
+ let noise = 0;
151
+ let unparseable = 0;
152
+ let emitted = 0;
153
+ for (const entry of entries) {
154
+ if (entry.isAd) {
155
+ ads++;
156
+ continue;
157
+ }
158
+ // "Visited" (ad clicks) and "Used Shorts creation tools" aren't watches.
159
+ if (entry.verb !== 'Watched') {
160
+ noise++;
161
+ continue;
162
+ }
163
+ const videoId = entry.titleHref && videoIdFromUrl(entry.titleHref);
164
+ if (!videoId) {
165
+ unparseable++;
166
+ continue;
167
+ }
168
+ if (!this.inRange(entry.time))
169
+ continue;
170
+ const record = {
171
+ videoId,
172
+ title: entry.titleText,
173
+ titleUrl: entry.titleHref,
174
+ channelId: entry.channelId,
175
+ channelName: entry.channelName,
176
+ time: entry.time.toISOString(),
177
+ };
178
+ yield ['watches', record];
179
+ emitted++;
180
+ }
181
+ this.logger.info(`Watch history: ${emitted} watches (skipped ${ads} ads, ${noise} non-watch entries, ${unparseable} without a video id)`);
182
+ }
183
+ async *extractSearches() {
184
+ const path = this.inputPath(FILES.search);
185
+ if (!(await this.fileExists(path))) {
186
+ this.logger.warn(`No search history at ${FILES.search} — skipping`);
187
+ return;
188
+ }
189
+ const entries = parseActivityHtml(await readFile(path, 'utf-8'));
190
+ let skipped = 0;
191
+ let emitted = 0;
192
+ for (const entry of entries) {
193
+ // The search file also carries ad-impression "Watched" rows and ad-click
194
+ // "Visited" rows; only real searches are activity.
195
+ if (entry.isAd || entry.verb !== 'Searched for' || !entry.titleText) {
196
+ skipped++;
197
+ continue;
198
+ }
199
+ if (!this.inRange(entry.time))
200
+ continue;
201
+ const record = {
202
+ query: entry.titleText,
203
+ time: entry.time.toISOString(),
204
+ };
205
+ yield ['searches', record];
206
+ emitted++;
207
+ }
208
+ this.logger.info(`Search history: ${emitted} searches (skipped ${skipped} ads/non-search entries)`);
209
+ }
210
+ async *extractSubscriptions() {
211
+ const path = this.inputPath(FILES.subscriptions);
212
+ if (!(await this.fileExists(path)))
213
+ return;
214
+ for (const row of await readTakeoutCsv(path)) {
215
+ const channelId = row['Channel Id'];
216
+ if (!channelId)
217
+ continue;
218
+ const record = {
219
+ channelId,
220
+ channelTitle: row['Channel Title'] || undefined,
221
+ };
222
+ yield ['subscriptions', record];
223
+ }
224
+ }
225
+ async *extractPlaylists() {
226
+ const path = this.inputPath(FILES.playlists);
227
+ if (!(await this.fileExists(path)))
228
+ return;
229
+ const byTitle = new Map();
230
+ for (const row of await readTakeoutCsv(path)) {
231
+ const playlistId = row['Playlist ID'];
232
+ const createdAt = row['Playlist Create Timestamp'];
233
+ if (!playlistId || !createdAt)
234
+ continue;
235
+ const record = {
236
+ playlistId,
237
+ title: row['Playlist Title (Original)'] ?? '',
238
+ createdAt,
239
+ visibility: row['Playlist Visibility'] || undefined,
240
+ };
241
+ byTitle.set(record.title, record);
242
+ yield ['playlists', record];
243
+ }
244
+ // Membership files are named after the playlist title; a file whose title
245
+ // no longer matches playlists.csv has no playlist id to key against.
246
+ for (const [title, itemPath] of await this.playlistItemFiles()) {
247
+ const playlist = byTitle.get(title);
248
+ if (!playlist) {
249
+ this.logger.warn(`Playlist file "${basename(itemPath)}" has no matching row in playlists.csv — skipping`);
250
+ continue;
251
+ }
252
+ for (const row of await readTakeoutCsv(itemPath)) {
253
+ const videoId = row['Video ID'];
254
+ const addedAt = row['Playlist Video Creation Timestamp'];
255
+ if (!videoId || !addedAt)
256
+ continue;
257
+ const record = {
258
+ playlistId: playlist.playlistId,
259
+ playlistTitle: playlist.title,
260
+ videoId,
261
+ addedAt,
262
+ };
263
+ yield ['playlist-items', record];
264
+ }
265
+ }
266
+ }
267
+ async *extractUploads() {
268
+ const path = this.inputPath(FILES.uploads);
269
+ if (!(await this.fileExists(path)))
270
+ return;
271
+ let drafts = 0;
272
+ for (const row of await readTakeoutCsv(path)) {
273
+ const videoId = row['Video ID'];
274
+ if (!videoId)
275
+ continue;
276
+ // A row without a publish timestamp is an unpublished draft/private
277
+ // upload — there is no publication occurrence to assert.
278
+ const publishedAt = row['Video Publish Timestamp'];
279
+ if (!publishedAt) {
280
+ drafts++;
281
+ continue;
282
+ }
283
+ const record = {
284
+ videoId,
285
+ title: row['Video Title (Original)'] || undefined,
286
+ description: row['Video Description (Original)'] || undefined,
287
+ durationMs: row['Approx Duration (ms)'] || undefined,
288
+ publishedAt,
289
+ };
290
+ yield ['uploads', record];
291
+ }
292
+ if (drafts > 0) {
293
+ this.logger.info(`Uploads: skipped ${drafts} unpublished draft(s)`);
294
+ }
295
+ }
296
+ async *extractComments() {
297
+ const path = this.inputPath(FILES.comments);
298
+ if (!(await this.fileExists(path)))
299
+ return;
300
+ for (const row of await readTakeoutCsv(path)) {
301
+ const commentId = row['Comment ID'];
302
+ const videoId = row['Video ID'];
303
+ const createdAt = row['Comment Create Timestamp'];
304
+ if (!commentId || !videoId || !createdAt)
305
+ continue;
306
+ const record = {
307
+ commentId,
308
+ videoId,
309
+ rawText: row['Comment Text'] ?? '',
310
+ createdAt,
311
+ };
312
+ yield ['comments', record];
313
+ }
314
+ }
315
+ }
@@ -0,0 +1,27 @@
1
+ import { ChronicleTransformer, Record } from '@chronicle.app/etl';
2
+ import { ActionAndChildren } from '@chronicle.app/schema';
3
+ /**
4
+ * Transforms a Google Takeout "YouTube and YouTube Music" archive into the
5
+ * same shapes the API transformer emits — identical entity keysets, URL
6
+ * builders, and second-precision timestamps — so a full archive import and
7
+ * API crawls fold onto the same actions and entities instead of duplicating
8
+ * them.
9
+ */
10
+ export default class YouTubeTakeoutTransformer extends ChronicleTransformer {
11
+ transform(record: Record): Promise<ActionAndChildren[]>;
12
+ private buildWatchAction;
13
+ private buildSearchAction;
14
+ private buildFollowAction;
15
+ private buildPlaylistCreateAction;
16
+ private buildPlaylistAddAction;
17
+ private buildUploadPublishAction;
18
+ private buildCommentAnnotateAction;
19
+ private commentBody;
20
+ private buildVideo;
21
+ private buildBareVideo;
22
+ private buildCollection;
23
+ private buildChannelAgent;
24
+ private agentScopedKey;
25
+ private resolveSelf;
26
+ }
27
+ //# sourceMappingURL=YouTubeTakeoutTransformer.d.ts.map
@@ -0,0 +1,260 @@
1
+ import { ChronicleTransformer, selfAgent } from '@chronicle.app/etl';
2
+ import { msToIsoDuration, ytTime } from '../utils/time.js';
3
+ import { channelUrl, playlistUrl, videoUrl } from '../utils/urls.js';
4
+ /**
5
+ * Transforms a Google Takeout "YouTube and YouTube Music" archive into the
6
+ * same shapes the API transformer emits — identical entity keysets, URL
7
+ * builders, and second-precision timestamps — so a full archive import and
8
+ * API crawls fold onto the same actions and entities instead of duplicating
9
+ * them.
10
+ */
11
+ export default class YouTubeTakeoutTransformer extends ChronicleTransformer {
12
+ async transform(record) {
13
+ switch (record.extraction.recordType) {
14
+ case 'watches': {
15
+ return this.buildWatchAction(record);
16
+ }
17
+ case 'searches': {
18
+ return this.buildSearchAction(record);
19
+ }
20
+ case 'subscriptions': {
21
+ return this.buildFollowAction(record);
22
+ }
23
+ case 'playlists': {
24
+ return this.buildPlaylistCreateAction(record);
25
+ }
26
+ case 'playlist-items': {
27
+ return this.buildPlaylistAddAction(record);
28
+ }
29
+ case 'uploads': {
30
+ return this.buildUploadPublishAction(record);
31
+ }
32
+ case 'comments': {
33
+ return this.buildCommentAnnotateAction(record);
34
+ }
35
+ default: {
36
+ return [];
37
+ }
38
+ }
39
+ }
40
+ buildWatchAction(record) {
41
+ const watch = record.data;
42
+ const agent = this.resolveSelf(record);
43
+ const action = {
44
+ '@type': 'WatchAction',
45
+ '@key': this.agentScopedKey(agent, 'object.sourceId'),
46
+ source: 'youtube',
47
+ timestamp: ytTime(watch.time),
48
+ agent,
49
+ object: this.buildVideo(watch),
50
+ };
51
+ return [action];
52
+ }
53
+ buildSearchAction(record) {
54
+ const search = record.data;
55
+ const agent = this.resolveSelf(record);
56
+ const query = {
57
+ '@type': 'Query',
58
+ '@key': ['@type', 'source', 'body'],
59
+ source: 'youtube',
60
+ name: search.query,
61
+ body: search.query,
62
+ };
63
+ const action = {
64
+ '@type': 'FindAction',
65
+ '@key': this.agentScopedKey(agent, 'object.body'),
66
+ source: 'youtube',
67
+ timestamp: ytTime(search.time),
68
+ agent,
69
+ object: query,
70
+ };
71
+ return [action];
72
+ }
73
+ // Takeout reports which channels are followed but not since when — the
74
+ // record carries no occurrence, only the snapshot's @assertedAt. Keyed on
75
+ // the channel id exactly like the API FollowAction, so both paths converge
76
+ // on one action and the API's real subscribe date owns the occurrence.
77
+ buildFollowAction(record) {
78
+ const subscription = record.data;
79
+ const action = {
80
+ '@type': 'FollowAction',
81
+ '@key': ['@type', 'source', 'sourceId'],
82
+ source: 'youtube',
83
+ sourceId: subscription.channelId,
84
+ agent: this.resolveSelf(record),
85
+ object: this.buildChannelAgent(subscription.channelId, subscription.channelTitle),
86
+ };
87
+ return [action];
88
+ }
89
+ buildPlaylistCreateAction(record) {
90
+ const playlist = record.data;
91
+ const action = {
92
+ '@type': 'CreateAction',
93
+ '@key': ['@type', 'source', 'sourceId'],
94
+ source: 'youtube',
95
+ sourceId: playlist.playlistId,
96
+ timestamp: ytTime(playlist.createdAt),
97
+ agent: this.resolveSelf(record),
98
+ result: this.buildCollection(playlist.playlistId, {
99
+ name: playlist.title,
100
+ visibility: playlist.visibility,
101
+ }),
102
+ };
103
+ return [action];
104
+ }
105
+ // Same membership key as the API path — (video, playlist, add instant) —
106
+ // so an archive import and an API crawl of the same playlist fold onto one
107
+ // AddAction.
108
+ buildPlaylistAddAction(record) {
109
+ const item = record.data;
110
+ const action = {
111
+ '@type': 'AddAction',
112
+ '@key': ['@type', 'source', 'object.sourceId', 'target.sourceId', 'timestamp'],
113
+ source: 'youtube',
114
+ timestamp: ytTime(item.addedAt),
115
+ agent: this.resolveSelf(record),
116
+ object: this.buildBareVideo(item.videoId),
117
+ target: this.buildCollection(item.playlistId, {
118
+ name: item.playlistTitle,
119
+ }),
120
+ };
121
+ return [action];
122
+ }
123
+ // Mirrors the API upload mapping: the action's timestamp IS datePublished, so
124
+ // the video carries no datePublished scalar of its own.
125
+ buildUploadPublishAction(record) {
126
+ const upload = record.data;
127
+ const video = {
128
+ ...this.buildBareVideo(upload.videoId),
129
+ ...(upload.title && { name: upload.title }),
130
+ ...(upload.description && { description: upload.description }),
131
+ ...(upload.durationMs && {
132
+ duration: msToIsoDuration(Number(upload.durationMs)),
133
+ }),
134
+ };
135
+ const action = {
136
+ '@type': 'PublishAction',
137
+ '@key': ['@type', 'source', 'sourceId'],
138
+ source: 'youtube',
139
+ sourceId: upload.videoId,
140
+ timestamp: ytTime(upload.publishedAt),
141
+ agent: this.resolveSelf(record),
142
+ object: video,
143
+ };
144
+ return [action];
145
+ }
146
+ buildCommentAnnotateAction(record) {
147
+ const data = record.data;
148
+ const agent = this.resolveSelf(record);
149
+ const video = this.buildBareVideo(data.videoId);
150
+ const comment = {
151
+ '@type': 'Comment',
152
+ '@key': ['@type', 'source', 'sourceId'],
153
+ source: 'youtube',
154
+ sourceId: data.commentId,
155
+ body: this.commentBody(data.rawText),
156
+ author: [agent],
157
+ about: [video],
158
+ };
159
+ const action = {
160
+ '@type': 'AnnotateAction',
161
+ '@key': ['@type', 'source', 'sourceId'],
162
+ source: 'youtube',
163
+ sourceId: data.commentId,
164
+ timestamp: ytTime(data.createdAt),
165
+ agent,
166
+ object: video,
167
+ result: comment,
168
+ };
169
+ return [action];
170
+ }
171
+ // Takeout wraps comment text as JSON ({"text": "..."}); the raw string is
172
+ // the fallback for rows that don't parse.
173
+ commentBody(rawText) {
174
+ try {
175
+ const parsed = JSON.parse(rawText);
176
+ if (typeof parsed?.text === 'string')
177
+ return parsed.text;
178
+ }
179
+ catch {
180
+ // fall through
181
+ }
182
+ return rawText;
183
+ }
184
+ buildVideo(watch) {
185
+ // A deleted/private video's entry degrades to the bare watch URL in place
186
+ // of a title — boilerplate, not the video's name (mirrors the API path's
187
+ // placeholder handling).
188
+ const unavailable = !watch.title || watch.title === watch.titleUrl;
189
+ return {
190
+ ...this.buildBareVideo(watch.videoId),
191
+ ...(!unavailable && { name: watch.title }),
192
+ ...(watch.channelId && {
193
+ author: [this.buildChannelAgent(watch.channelId, watch.channelName)],
194
+ }),
195
+ };
196
+ }
197
+ buildBareVideo(videoId) {
198
+ return {
199
+ '@type': 'VideoObject',
200
+ '@key': ['@type', 'source', 'sourceId'],
201
+ source: 'youtube',
202
+ sourceId: videoId,
203
+ url: videoUrl(videoId),
204
+ };
205
+ }
206
+ buildCollection(playlistId, opts) {
207
+ return {
208
+ '@type': 'Collection',
209
+ '@key': ['@type', 'source', 'sourceId'],
210
+ source: 'youtube',
211
+ sourceId: playlistId,
212
+ url: playlistUrl(playlistId),
213
+ ...(opts.name && { name: opts.name }),
214
+ // The API reports privacyStatus lowercase; Takeout capitalizes. The
215
+ // schema's visibility tokens are lowercase, and a casing mismatch would
216
+ // flip-flop the run on every alternating import.
217
+ ...(opts.visibility && { visibility: opts.visibility.toLowerCase() }),
218
+ };
219
+ }
220
+ // A YouTube channel as an Agent — the same shape (and so the same keyset)
221
+ // as the API transformer's channels, with the canonical https url in place
222
+ // of Takeout's http:// links.
223
+ buildChannelAgent(channelId, name) {
224
+ return {
225
+ '@type': 'Agent',
226
+ '@key': ['@type', 'source', 'handle'],
227
+ source: 'youtube',
228
+ handle: channelId,
229
+ url: channelUrl(channelId),
230
+ ...(name && { name }),
231
+ };
232
+ }
233
+ // Watch/search actions key on the acting handle like the API LikeAction; an
234
+ // archive without channels/channel.csv has no handle, so the key drops to
235
+ // the remaining components rather than referencing a missing field.
236
+ agentScopedKey(agent, objectComponent) {
237
+ return agent.handle
238
+ ? ['@type', 'source', 'agent.handle', objectComponent, 'timestamp']
239
+ : ['@type', 'source', objectComponent, 'timestamp'];
240
+ }
241
+ // The account owner from channels/channel.csv (a real channel id, the same
242
+ // handle keyset as the API self, so the two merge directly); an archive
243
+ // missing that file falls back to the per-source singleton, still folded
244
+ // into the person through @me.
245
+ resolveSelf(record) {
246
+ const self = record.context?.selfChannel;
247
+ if (self?.id) {
248
+ return {
249
+ ...selfAgent({
250
+ type: 'Agent',
251
+ source: 'youtube',
252
+ handle: self.id,
253
+ name: self.title,
254
+ }),
255
+ url: channelUrl(self.id),
256
+ };
257
+ }
258
+ return selfAgent({ type: 'Agent', source: 'youtube' });
259
+ }
260
+ }
@@ -0,0 +1,15 @@
1
+ import { ChronicleTransformer, Record } from '@chronicle.app/etl';
2
+ import { ActionAndChildren } from '@chronicle.app/schema';
3
+ export default class YouTubeTransformer extends ChronicleTransformer {
4
+ transform(record: Record): Promise<ActionAndChildren[]>;
5
+ private buildLikeAction;
6
+ private buildFollowAction;
7
+ private buildPlaylistCreateAction;
8
+ private buildPlaylistAddAction;
9
+ private buildUploadPublishAction;
10
+ private buildCollection;
11
+ private buildVideo;
12
+ private buildChannelAgent;
13
+ private resolveSelf;
14
+ }
15
+ //# sourceMappingURL=YouTubeTransformer.d.ts.map