@chronicle.app/youtube 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,18 @@ Extract liked videos and channel subscriptions from YouTube via the Data API v3
10
10
  - **Uploads**: Extract videos published to your own channel
11
11
  - **OAuth Authentication**: Google OAuth 2.0 with offline refresh tokens
12
12
 
13
- Watch and search history are not exposed by the Data API (Google Takeout only) and are out of scope for this plugin.
13
+ Watch and search history aren't in the Data API. The `takeout` strategy reads
14
+ them, with the rest of the library, from a Google Takeout:
15
+
16
+ ```bash
17
+ chronicle extract youtube --strategy takeout --input ~/Downloads/takeout-20260725T000000Z-001.zip
18
+ ```
19
+
20
+ It takes the Takeout as a zip, one part of a split Takeout, the unzipped folder,
21
+ or its `YouTube and YouTube Music` folder, and reads a zip in place.
22
+ `chronicle scan <path>` finds a Takeout in a folder of exports. It reads history
23
+ in HTML, as Takeout writes it by default; a Takeout with its history in JSON
24
+ isn't read yet.
14
25
 
15
26
  ## Setup
16
27
 
@@ -47,12 +58,12 @@ chronicle extract youtube --type uploads
47
58
  ## Data Mapping
48
59
 
49
60
  - Liked video → `LikeAction` by the account owner (`@me`) on a `VideoObject` (title, description, duration, thumbnail, uploading channel as author)
50
- - Subscription → `FollowAction` on the channel as an `Agent`, keyed on the channel id so re-runs supersede
61
+ - Subscription → `FollowAction` on the channel as an `Agent`, keyed by the channel it follows so re-runs supersede
51
62
  - Playlist → `Collection` (with `visibility` from the privacy status), created via a `CreateAction` at the playlist's real creation time; each video in it → `AddAction` keyed on the playlist item id with the `Collection` as `targetCollection`
52
63
  - Upload → `PublishAction` with the account owner as agent and the `VideoObject` as result
53
64
  - Channels are `Agent`s keyed on their YouTube channel id
54
65
 
55
66
  Tests run the pipeline against a fake Data API served on 127.0.0.1 with a fake
56
- access token, and against a small synthetic Takeout folder in a temporary
57
- directory. They never call Google, read stored credentials, or read the host's
67
+ access token, and against a small synthetic Takeout, unzipped and zipped, in a
68
+ temporary directory. They never call Google, read stored credentials, or read the host's
58
69
  data or accounts.
@@ -1,4 +1,4 @@
1
- import { Extractor, Record } from '@chronicle.app/etl';
1
+ import { Extractor, Record, type Survey, type SurveyOptions } from '@chronicle.app/etl';
2
2
  import { z } from 'zod';
3
3
  import YouTubeTakeoutTransformer from './YouTubeTakeoutTransformer.js';
4
4
  export interface TakeoutSelfChannel {
@@ -72,16 +72,30 @@ export declare class YouTubeTakeoutExtractor extends Extractor<typeof YouTubeTak
72
72
  until?: Date | undefined;
73
73
  input?: string | undefined;
74
74
  }>;
75
+ private files;
76
+ /** Where the YouTube folder is in the export, as a path prefix. */
77
+ private prefix;
75
78
  private archiveAsOf;
76
79
  private selfChannel;
80
+ /** The path in the export of a file in the YouTube folder. */
77
81
  private inputPath;
78
82
  private fileExists;
83
+ private readCsv;
79
84
  setup(): Promise<void>;
85
+ teardown(): Promise<void>;
80
86
  protected asOfTime(): Date;
81
87
  private inRange;
82
88
  /** `[playlist title, csv path]` for each per-playlist membership file. */
83
89
  private playlistItemFiles;
84
90
  extract(): AsyncGenerator<Record>;
91
+ /**
92
+ * The records a run reads and when each happened, from every section: the
93
+ * archive has no index, so each file is read. Subscriptions are as of the
94
+ * archive, and watches say how many videos they were of.
95
+ */
96
+ survey(options?: SurveyOptions): Promise<Survey>;
97
+ /** Each section's records, in the order a run reads them, as `[record type, data]`. */
98
+ private readSections;
85
99
  private extractWatches;
86
100
  private extractSearches;
87
101
  private extractSubscriptions;
@@ -1,10 +1,14 @@
1
- import { Extractor } from '@chronicle.app/etl';
2
- import { readdir, readFile, stat } from 'node:fs/promises';
3
- import { basename, join } from 'node:path';
1
+ import { Extractor, InputNotFound, countMonth, openExport, } from '@chronicle.app/etl';
4
2
  import { z } from 'zod';
5
- import { readTakeoutCsv } from '../takeout/csv.js';
3
+ import { parseTakeoutCsv } from '../takeout/csv.js';
6
4
  import { parseActivityHtml, videoIdFromUrl } from '../takeout/parseActivityHtml.js';
5
+ import { ytTime } from '../utils/time.js';
7
6
  import YouTubeTakeoutTransformer from './YouTubeTakeoutTransformer.js';
7
+ // The folder Takeout puts YouTube's files in.
8
+ const PRODUCT = 'YouTube and YouTube Music';
9
+ // Where that folder can be in an export: the export itself, as an unpacked
10
+ // folder was passed before, in the Takeout folder, or in the zip that holds it.
11
+ const PREFIXES = ['', `${PRODUCT}/`, `Takeout/${PRODUCT}/`];
8
12
  // Relative paths of the archive files this extractor reads.
9
13
  const FILES = {
10
14
  watch: 'history/watch-history.html',
@@ -15,6 +19,16 @@ const FILES = {
15
19
  comments: 'comments/comments.csv',
16
20
  channel: 'channels/channel.csv',
17
21
  };
22
+ // The time the transformer dates each kind by. Takeout doesn't say when a
23
+ // subscription began, so it has none.
24
+ const DATED = {
25
+ watches: data => data.time,
26
+ searches: data => data.time,
27
+ playlists: data => data.createdAt,
28
+ 'playlist-items': data => data.addedAt,
29
+ uploads: data => data.publishedAt,
30
+ comments: data => data.createdAt,
31
+ };
18
32
  export class YouTubeTakeoutExtractor extends Extractor {
19
33
  static source = 'youtube';
20
34
  static description = 'Watch and search history and library from Takeout';
@@ -40,49 +54,63 @@ export class YouTubeTakeoutExtractor extends Extractor {
40
54
  // flags across a source's extractors, so a schema-required flag here would
41
55
  // also gate the API extractors that share the youtube source.
42
56
  static schema = Extractor.schema.extend({
43
- input: z.string().optional().describe('Path to the Takeout "YouTube and YouTube Music" folder'),
57
+ input: z
58
+ .string()
59
+ .optional()
60
+ .describe(`The Takeout, as a folder or zip, or its "${PRODUCT}" folder`),
44
61
  });
62
+ files = null;
63
+ /** Where the YouTube folder is in the export, as a path prefix. */
64
+ prefix = '';
45
65
  archiveAsOf = null;
46
66
  selfChannel = null;
67
+ /** The path in the export of a file in the YouTube folder. */
47
68
  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);
69
+ return this.prefix + relative;
53
70
  }
54
71
  async fileExists(path) {
55
- try {
56
- await stat(path);
57
- return true;
58
- }
59
- catch {
60
- return false;
61
- }
72
+ return this.files.has(path);
73
+ }
74
+ async readCsv(path) {
75
+ return parseTakeoutCsv(await this.files.readText(path));
62
76
  }
63
77
  async setup() {
64
78
  await super.setup();
79
+ const config = this.config;
80
+ if (!config.input) {
81
+ throw new Error(`The takeout extractor needs --input: the Takeout, as a folder or zip, or its "${PRODUCT}" folder`);
82
+ }
83
+ this.files = await openExport(config.input);
84
+ const listed = new Set(await this.files.list());
85
+ const prefix = PREFIXES.find(p => Object.values(FILES).some(f => listed.has(p + f)));
86
+ if (prefix === undefined) {
87
+ await this.teardown();
88
+ throw new InputNotFound(`No ${PRODUCT} folder in the export`, {
89
+ path: config.input,
90
+ hint: `Pass the Takeout, as a folder or zip, or its "${PRODUCT}" folder with \`--input <path>\`.`,
91
+ });
92
+ }
93
+ this.prefix = prefix;
65
94
  // The archive is a static artifact: its sighting time is the export's,
66
95
  // 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.
96
+ // no-op. Newest mtime across the files read stands in for the export time;
97
+ // in a zip, the time its index gives each file.
68
98
  const paths = [
69
99
  ...Object.values(FILES).map(f => this.inputPath(f)),
70
100
  ...(await this.playlistItemFiles()).map(([, path]) => path),
71
101
  ];
72
102
  let newest = 0;
73
103
  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
- }
104
+ // Missing sections are fine; each is skipped with a warning later.
105
+ const modified = await this.files.modified(path);
106
+ if (modified)
107
+ newest = Math.max(newest, modified.getTime());
80
108
  }
81
109
  if (newest > 0)
82
110
  this.archiveAsOf = new Date(newest);
83
111
  const channelPath = this.inputPath(FILES.channel);
84
112
  if (await this.fileExists(channelPath)) {
85
- const [row] = await readTakeoutCsv(channelPath);
113
+ const [row] = await this.readCsv(channelPath);
86
114
  const id = row?.['Channel ID'];
87
115
  if (id) {
88
116
  this.selfChannel = {
@@ -95,6 +123,11 @@ export class YouTubeTakeoutExtractor extends Extractor {
95
123
  this.logger.warn('channels/channel.csv missing or empty — the account owner falls back to the per-source singleton self');
96
124
  }
97
125
  }
126
+ async teardown() {
127
+ await this.files?.close();
128
+ this.files = null;
129
+ await super.teardown();
130
+ }
98
131
  asOfTime() {
99
132
  return this.archiveAsOf ?? super.asOfTime();
100
133
  }
@@ -108,44 +141,74 @@ export class YouTubeTakeoutExtractor extends Extractor {
108
141
  }
109
142
  /** `[playlist title, csv path]` for each per-playlist membership file. */
110
143
  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
- }
144
+ const dir = this.inputPath('playlists/');
145
+ return (await this.files.list(dir))
146
+ .map(path => path.slice(dir.length))
147
+ .filter(name => !name.includes('/') && name.endsWith('-videos.csv'))
148
+ .map(name => [name.slice(0, -'-videos.csv'.length), dir + name]);
121
149
  }
122
150
  async *extract() {
123
151
  let count = 0;
124
152
  const self = this.selfChannel;
125
153
  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
- }
154
+ for await (const [recordType, data] of this.readSections()) {
155
+ if (this.shouldStopExtracting(count))
156
+ return;
157
+ yield emit(recordType, data);
158
+ count++;
140
159
  }
141
160
  }
161
+ /**
162
+ * The records a run reads and when each happened, from every section: the
163
+ * archive has no index, so each file is read. Subscriptions are as of the
164
+ * archive, and watches say how many videos they were of.
165
+ */
166
+ async survey(options = {}) {
167
+ const kinds = {};
168
+ for (const recordType of YouTubeTakeoutExtractor.recordTypes) {
169
+ kinds[recordType] = {
170
+ count: 0,
171
+ ...(options.byMonth && DATED[recordType] && { byMonth: {} }),
172
+ };
173
+ }
174
+ const videos = new Set();
175
+ for await (const [recordType, data] of this.readSections()) {
176
+ const kind = kinds[recordType];
177
+ kind.count++;
178
+ if (recordType === 'watches')
179
+ videos.add(data.videoId);
180
+ const dated = DATED[recordType];
181
+ if (!dated)
182
+ continue;
183
+ const time = ytTime(dated(data));
184
+ if (!kind.earliest || time < kind.earliest)
185
+ kind.earliest = time;
186
+ if (!kind.latest || time > kind.latest)
187
+ kind.latest = time;
188
+ if (kind.byMonth)
189
+ countMonth(kind.byMonth, time);
190
+ }
191
+ if (this.archiveAsOf)
192
+ kinds.subscriptions.asOf = this.archiveAsOf;
193
+ kinds.watches.measures = [{ key: 'videos', label: 'videos', value: videos.size }];
194
+ return kinds;
195
+ }
196
+ /** Each section's records, in the order a run reads them, as `[record type, data]`. */
197
+ async *readSections() {
198
+ yield* this.extractWatches();
199
+ yield* this.extractSearches();
200
+ yield* this.extractSubscriptions();
201
+ yield* this.extractPlaylists();
202
+ yield* this.extractUploads();
203
+ yield* this.extractComments();
204
+ }
142
205
  async *extractWatches() {
143
206
  const path = this.inputPath(FILES.watch);
144
207
  if (!(await this.fileExists(path))) {
145
208
  this.logger.warn(`No watch history at ${FILES.watch} — skipping`);
146
209
  return;
147
210
  }
148
- const entries = parseActivityHtml(await readFile(path, 'utf-8'));
211
+ const entries = parseActivityHtml(await this.files.readText(path));
149
212
  let ads = 0;
150
213
  let noise = 0;
151
214
  let unparseable = 0;
@@ -186,7 +249,7 @@ export class YouTubeTakeoutExtractor extends Extractor {
186
249
  this.logger.warn(`No search history at ${FILES.search} — skipping`);
187
250
  return;
188
251
  }
189
- const entries = parseActivityHtml(await readFile(path, 'utf-8'));
252
+ const entries = parseActivityHtml(await this.files.readText(path));
190
253
  let skipped = 0;
191
254
  let emitted = 0;
192
255
  for (const entry of entries) {
@@ -211,7 +274,7 @@ export class YouTubeTakeoutExtractor extends Extractor {
211
274
  const path = this.inputPath(FILES.subscriptions);
212
275
  if (!(await this.fileExists(path)))
213
276
  return;
214
- for (const row of await readTakeoutCsv(path)) {
277
+ for (const row of await this.readCsv(path)) {
215
278
  const channelId = row['Channel Id'];
216
279
  if (!channelId)
217
280
  continue;
@@ -227,7 +290,7 @@ export class YouTubeTakeoutExtractor extends Extractor {
227
290
  if (!(await this.fileExists(path)))
228
291
  return;
229
292
  const byTitle = new Map();
230
- for (const row of await readTakeoutCsv(path)) {
293
+ for (const row of await this.readCsv(path)) {
231
294
  const playlistId = row['Playlist ID'];
232
295
  const createdAt = row['Playlist Create Timestamp'];
233
296
  if (!playlistId || !createdAt)
@@ -246,10 +309,10 @@ export class YouTubeTakeoutExtractor extends Extractor {
246
309
  for (const [title, itemPath] of await this.playlistItemFiles()) {
247
310
  const playlist = byTitle.get(title);
248
311
  if (!playlist) {
249
- this.logger.warn(`Playlist file "${basename(itemPath)}" has no matching row in playlists.csv — skipping`);
312
+ this.logger.warn(`Playlist file "${itemPath.split('/').at(-1)}" has no matching row in playlists.csv — skipping`);
250
313
  continue;
251
314
  }
252
- for (const row of await readTakeoutCsv(itemPath)) {
315
+ for (const row of await this.readCsv(itemPath)) {
253
316
  const videoId = row['Video ID'];
254
317
  const addedAt = row['Playlist Video Creation Timestamp'];
255
318
  if (!videoId || !addedAt)
@@ -269,7 +332,7 @@ export class YouTubeTakeoutExtractor extends Extractor {
269
332
  if (!(await this.fileExists(path)))
270
333
  return;
271
334
  let drafts = 0;
272
- for (const row of await readTakeoutCsv(path)) {
335
+ for (const row of await this.readCsv(path)) {
273
336
  const videoId = row['Video ID'];
274
337
  if (!videoId)
275
338
  continue;
@@ -297,7 +360,7 @@ export class YouTubeTakeoutExtractor extends Extractor {
297
360
  const path = this.inputPath(FILES.comments);
298
361
  if (!(await this.fileExists(path)))
299
362
  return;
300
- for (const row of await readTakeoutCsv(path)) {
363
+ for (const row of await this.readCsv(path)) {
301
364
  const commentId = row['Comment ID'];
302
365
  const videoId = row['Video ID'];
303
366
  const createdAt = row['Comment Create Timestamp'];
@@ -78,9 +78,9 @@ export default class YouTubeTakeoutTransformer extends ChronicleTransformer {
78
78
  const subscription = record.data;
79
79
  const action = {
80
80
  '@type': 'FollowAction',
81
- '@key': ['@type', 'source', 'sourceId'],
81
+ // Keyed by the channel it follows, as the API path keys it.
82
+ '@key': ['@type', 'source', 'object.handle'],
82
83
  source: 'youtube',
83
- sourceId: subscription.channelId,
84
84
  agent: this.resolveSelf(record),
85
85
  object: this.buildChannelAgent(subscription.channelId, subscription.channelTitle),
86
86
  };
@@ -90,9 +90,9 @@ export default class YouTubeTakeoutTransformer extends ChronicleTransformer {
90
90
  const playlist = record.data;
91
91
  const action = {
92
92
  '@type': 'CreateAction',
93
- '@key': ['@type', 'source', 'sourceId'],
93
+ // Keyed by the playlist it made, as the API path keys it.
94
+ '@key': ['@type', 'source', 'result.sourceId'],
94
95
  source: 'youtube',
95
- sourceId: playlist.playlistId,
96
96
  timestamp: ytTime(playlist.createdAt),
97
97
  agent: this.resolveSelf(record),
98
98
  result: this.buildCollection(playlist.playlistId, {
@@ -134,12 +134,12 @@ export default class YouTubeTakeoutTransformer extends ChronicleTransformer {
134
134
  };
135
135
  const action = {
136
136
  '@type': 'PublishAction',
137
- '@key': ['@type', 'source', 'sourceId'],
137
+ // Keyed by the video it published, as the API path keys it.
138
+ '@key': ['@type', 'source', 'result.sourceId'],
138
139
  source: 'youtube',
139
- sourceId: upload.videoId,
140
140
  timestamp: ytTime(upload.publishedAt),
141
141
  agent: this.resolveSelf(record),
142
- object: video,
142
+ result: video,
143
143
  };
144
144
  return [action];
145
145
  }
@@ -158,9 +158,9 @@ export default class YouTubeTakeoutTransformer extends ChronicleTransformer {
158
158
  };
159
159
  const action = {
160
160
  '@type': 'AnnotateAction',
161
- '@key': ['@type', 'source', 'sourceId'],
161
+ // Keyed by the comment it made.
162
+ '@key': ['@type', 'source', 'result.sourceId'],
162
163
  source: 'youtube',
163
- sourceId: data.commentId,
164
164
  timestamp: ytTime(data.createdAt),
165
165
  agent,
166
166
  object: video,
@@ -60,9 +60,9 @@ export default class YouTubeTransformer extends ChronicleTransformer {
60
60
  const { channelId } = subscription.snippet.resourceId;
61
61
  const action = {
62
62
  '@type': 'FollowAction',
63
- '@key': ['@type', 'source', 'sourceId'],
63
+ // Keyed by the channel it follows.
64
+ '@key': ['@type', 'source', 'object.handle'],
64
65
  source: 'youtube',
65
- sourceId: channelId,
66
66
  timestamp: ytTime(subscription.snippet.publishedAt),
67
67
  agent: this.resolveSelf(record),
68
68
  object: this.buildChannelAgent(channelId, {
@@ -79,9 +79,9 @@ export default class YouTubeTransformer extends ChronicleTransformer {
79
79
  const playlist = record.data;
80
80
  const action = {
81
81
  '@type': 'CreateAction',
82
- '@key': ['@type', 'source', 'sourceId'],
82
+ // Keyed by the playlist it made.
83
+ '@key': ['@type', 'source', 'result.sourceId'],
83
84
  source: 'youtube',
84
- sourceId: playlist.id,
85
85
  timestamp: ytTime(playlist.snippet.publishedAt),
86
86
  agent: this.resolveSelf(record),
87
87
  result: this.buildCollection(playlist),
@@ -118,12 +118,12 @@ export default class YouTubeTransformer extends ChronicleTransformer {
118
118
  delete video.datePublished;
119
119
  const action = {
120
120
  '@type': 'PublishAction',
121
- '@key': ['@type', 'source', 'sourceId'],
121
+ // Keyed by the video it published.
122
+ '@key': ['@type', 'source', 'result.sourceId'],
122
123
  source: 'youtube',
123
- sourceId: upload.item.snippet.resourceId.videoId,
124
124
  timestamp: ytTime(upload.item.snippet.publishedAt),
125
125
  agent: this.resolveSelf(record),
126
- object: video,
126
+ result: video,
127
127
  };
128
128
  return [action];
129
129
  }
@@ -1,4 +1,4 @@
1
1
  /** A Takeout CSV as header-keyed rows. Fields regularly carry embedded
2
2
  * newlines (descriptions, comment JSON), so this is never line-splittable. */
3
- export declare function readTakeoutCsv(path: string): Promise<Record<string, string>[]>;
3
+ export declare function parseTakeoutCsv(content: string): Record<string, string>[];
4
4
  //# sourceMappingURL=csv.d.ts.map
@@ -1,9 +1,7 @@
1
- import { readFile } from 'node:fs/promises';
2
1
  import { parse } from 'csv-parse/sync';
3
2
  /** A Takeout CSV as header-keyed rows. Fields regularly carry embedded
4
3
  * newlines (descriptions, comment JSON), so this is never line-splittable. */
5
- export async function readTakeoutCsv(path) {
6
- const content = await readFile(path, 'utf-8');
4
+ export function parseTakeoutCsv(content) {
7
5
  return parse(content, {
8
6
  columns: true,
9
7
  bom: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chronicle.app/youtube",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "main": "./dist/index.js",
5
5
  "types": "./dist/index.d.ts",
6
6
  "files": [
@@ -22,9 +22,9 @@
22
22
  "type": "module",
23
23
  "description": "Chronicle plugin for extracting data from YouTube",
24
24
  "devDependencies": {
25
- "@chronicle.app/etl": "0.4.0",
26
- "@chronicle.app/schema": "0.4.0",
27
- "@chronicle.app/tsconfig": "0.4.0",
25
+ "@chronicle.app/etl": "0.5.0",
26
+ "@chronicle.app/schema": "0.5.0",
27
+ "@chronicle.app/tsconfig": "0.5.0",
28
28
  "@types/node": "^22.10.1",
29
29
  "typescript": "^5.7.2"
30
30
  },
@@ -39,7 +39,7 @@
39
39
  "registry": "https://registry.npmjs.org/"
40
40
  },
41
41
  "dependencies": {
42
- "@chronicle.app/auth": "0.4.0",
42
+ "@chronicle.app/auth": "0.5.0",
43
43
  "axios": "^1.7.9",
44
44
  "csv-parse": "^5.6.0",
45
45
  "zod": "^3.24.1"
@@ -75,6 +75,20 @@
75
75
  },
76
76
  "takeout": {
77
77
  "delivery": "export",
78
+ "layouts": [
79
+ {
80
+ "files": [
81
+ "YouTube and YouTube Music/history/watch-history.html"
82
+ ]
83
+ },
84
+ {
85
+ "name": "JSON",
86
+ "files": [
87
+ "YouTube and YouTube Music/history/watch-history.json"
88
+ ],
89
+ "supported": false
90
+ }
91
+ ],
78
92
  "recordTypes": [
79
93
  "watches",
80
94
  "searches",