forked from daniloc/airtable-api-proxy
-
Notifications
You must be signed in to change notification settings - Fork 3
/
Copy pathesovdb.js
589 lines (513 loc) · 28.6 KB
/
esovdb.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
/**
* @file ESOVDB Airtable API methods
* @author Avana Vana <[email protected]>
* @module esovdb
* @see {@link https://airtable.com/shrFBKQwGjstk7TVn|The Earth Science Online Video Database}
*/
const dotenv = require('dotenv').config();
const Airtable = require('airtable');
const Bottleneck = require('bottleneck');
const cache = require('./cache');
const { getVideo } = require('./youtube');
const { formatDuration, formatDate, packageAuthors, sleep } = require('./util');
const base = new Airtable({
apiKey: process.env.AIRTABLE_API_KEY,
}).base(process.env.AIRTABLE_BASE_ID);
/** @constant {Map} tables - Maps request table params to their proper names on the ESOVDB */
const tables = new Map([
[ 'videos', 'Videos' ],
[ 'series', 'Series' ],
[ 'topics', 'Topics' ],
[ 'tags', 'Tags' ],
[ 'organizations', 'Organizations' ],
[ 'people', 'People' ],
[ 'submissions', 'Submissions' ],
[ 'issues', 'Issues ']
]);
/** @constant {number} airtableRateLimit - Minimum time in ms to wait between requests using {@link Bottleneck} (default: 201ms ⋍ just under 5 req/s) */
const airtableRateLimit = 1005 / 5;
/** @constant {RegExp} regexYT - Regular expression for matching and extracting a YouTube videoId from a URL or on its own */
const regexYT = /^(?!rec)(?![\w\-]{12,})(?:.*youtu\.be\/|.*v=)?([\w\-]{10,12})&?.*$/;
/** @constant {RegExp} regexYTVideoId - Regular expression for matching and extracting a YouTube videoId purely on its own */
const regexYTVideoId = /[\w\-]{10,12}/;
const rateLimiter = new Bottleneck({ minTime: airtableRateLimit });
/** @constant {Map} formatFields - Maps each format, as passed in the URL query params to a list of fields that Airtable should retrieve, for that format. */
const formatFields = new Map([
[ 'zotero', [ 'Zotero Key', 'Zotero Version', 'Series Zotero Key', 'Title', 'URL', 'Year', 'Description', 'Running Time', 'Format', 'Topic', 'Tags', 'Learn More', 'Series Text', 'Series Count Text', 'Vol.', 'No.', 'Publisher Text', 'Presenter First Name', 'Presenter Last Name', 'Language Code', 'Location', 'Plus Code', 'Video Provider', 'ESOVDBID', 'Record ID', 'ISO Added', 'Created', 'Modified' ]],
[ 'yt', [ 'YouTube Video ID', 'Record ID', 'ESOVDBID', 'Zotero Key', 'ISO Added' ]],
[ 'youtube', [ 'YouTube Video ID', 'Record ID', 'ESOVDBID', 'Zotero Key', 'ISO Added' ]]
]);
/** @constant {Object} videoFormat - A collection of formatting methods that can be used to transform ESOVDB Airtable output into different formats */
const videoFormat = {
/**
* Formats a video from the ESOVDB according to the Zotero item template specification, returning each as a JavaScript Object
*
* @method toZoteroJSON
* @param {AirtableRecord} record - The Airtable record class instance to format
* @returns {Object} An ESOVDB video, formatted according to the Zotero item template specification, for synchronizing with the Zotero library
*/
toZoteroJSON: (record) => ({
zoteroKey: record.get('Zotero Key') || '',
zoteroVersion: record.get('Zotero Version') || null,
zoteroSeries: record.get('Series Zotero Key') || '',
title: record.get('Title') || '',
url: record.get('URL') || '',
year: record.get('Year') || null,
desc: record.get('Description') || '',
runningTime: formatDuration(record.get('Running Time')) || '',
format: record.get('Format') || '',
topic: record.get('Topic') || '',
tags: record.get('Tags') || [],
learnMore: record.get('Learn More'),
series: record.get('Series Text') || '',
seriesCount: +record.get('Series Count Text') || '',
vol: record.get('Vol.') || null,
no: record.get('No.') || null,
publisher: record.get('Publisher Text') || '',
presenters: packageAuthors(record.get('Presenter First Name'), record.get('Presenter Last Name')),
language: record.get('Language Code') || '',
location: record.get('Location') || '',
plusCode: record.get('Plus Code') || '',
provider: record.get('Video Provider') || '',
esovdbId: record.get('ESOVDBID') || '',
recordId: record.get('Record ID') || '',
accessDate: formatDate(record.get('ISO Added')) || '',
created: record.get('Created'),
modified: record.get('Modified'),
}),
/**
* Formats a video from the ESOVDB to a JavaScript Object with useful information for locating it in either the ESOVDB or the Zotero library, by its YouTube videoId, if it has one
*
* @method toYTJSON
* @param {AirtableRecord} record - The Airtable record class instance to format
* @returns {Object} An ESOVDB video, formatted as a JavaScript Object with useful information for locating it in either the ESOVDB or the Zotero library, by its YouTube videoId, if it has one
*/
toYTJSON: (record) => ({
videoId: record.get('YouTube Video ID') || '',
recordId: record.get('Record ID') || '',
esovdbId: record.get('ESOVDBID') || '',
zoteroKey: record.get('Zotero Key') || '',
added: formatDate(record.get('ISO Added')) || ''
}),
/**
* Formats a video from the ESOVDB to a JavaScript object from the raw JSON provided by Airtable, including all available fields.
*
* @method toJSON
* @param {AirtableRecord} record - The Airtable record class instance to format
* @returns {Object} An ESOVDB video, formatted as a JavaScript Object based on the raw JSON response from Airtable, including all available fields.
*/
toJSON: (record) => ({ id: record.id, ...record._rawJson.fields }),
/**
* @method toCSV
* @todo Will eventually format an Airtable record class instance as a line in a CSV file to be included in a larger CSV response, i.e. with each field's value in order, separated by commas, and surrounded by double quotes, if the field's value contains a comma
*/
// toCSV: (record) => {},
/**
* @method toXML
* @todo Will eventually format an Airtable record class instance as an XML object to be included in a larger XML response
*/
// toXML: (video) => {},
/**
* @method toKML
* @todo Will eventually format an Airtable record class instance as an Google Earth KML object to be included in a larger Google Earth KML response, if the Airtable record has location data, such that the response can be imported into and plotted with Google Earth
*/
// toKML: (video) => {},
/**
* @method toGeoJSON
* @todo Will eventually format an Airtable record class instance as an GeoJSON Object, if the Airtable record has location data, such that the response can be used with GIS software
*/
// toGeoJSON: (video) => {}
}
/**
* Maps a URL query parameter for video format to the appropriate formatting function
*
* @function getFormat
* @param {Function} [def=videoFormat.toZoteroJSON] - The default formatting function to use, if no 'format' URL query parameter is sent with the request
* @param {string} [param=null] - The value of the URL query parameter 'format', sent with the request
* @returns {Function} A formatting method from the {@link videoFormat} object mapped to the URL query parameter, or by default, the {@link videoFormat.toZoteroJSON} method
*/
const getFormat = (param = null, def = videoFormat.toZoteroJSON) => {
switch (param) {
case 'raw':
case 'json':
return videoFormat.toJSON;
case 'zotero':
return videoFormat.toZoteroJSON;
case 'yt':
case 'youtube':
return videoFormat.toYTJSON;
default:
return def;
}
}
module.exports = {
/**
* Retrieves a query of videos by first checking the cache for a matching, fresh request, and otherwise performs an Airtable select() API query, page by page {@link req.query.pageSize} videos at a time (default=100), until all or {@link req.query.maxRecords}, if specified, using Botleneck for rate-limiting.
*
* @method queryVideos
* @requires Airtable
* @requires Bottleneck
* @requires cache
* @requires util
* @param {!express:Request} req - Express.js HTTP request context, an enhanced version of Node's http.IncomingMessage class
* @param {number} [req.params.pg] - An Express.js route param optionally passed after videos/query, which specifies which page (one-indexed) of a given {@link pageSize} number records should be sent in the [server response]{@link res}
* @param {number} [req.query.pageSize=100] - An [http request]{@link req} URL query param that specifies how many Airtable records to return in each API call
* @param {number} [req.query.maxRecords] - An [http request]{@link req} URL query param that specifies the maximum number of Airtable records that should be sent in the [server response]{@link res}
* @param {string} [req.query.createdAfter] - An [http request]{@link req} URL query param, in the format of a date string, parseable by Date.parse(), used to create a filterByFormula in an Airtable API call that returns only records created after the date in the given string
* @param {string} [req.query.modifiedAfter] - An [http request]{@link req} URL query param, in the format of a date string, parseable by Date.parse(), used to create a filterByFormula in an Airtable API call that returns only records modified after the date in the given string
* @param {string} [req.query.youTube] - A YouTube video's URL, short URL, or video ID
* @param {(!express:Response|Boolean)} res - Express.js HTTP response context, an enhanced version of Node's http.ServerResponse class, or false if not passed
* @sideEffects Queries the ESOVDB Airtable base, page by page, and either sends the retrieved data as JSON within an HTTPServerResponse object, or returns it as a JavaScript Object
* @returns {Object[]} Array of ESOVDB video records as JavaScript objects (if no {@link res} object is provided)
*/
queryVideos: (req, res = false) => {
if (!req.params) req.params = {};
if (!req.query) req.query = {};
req.params.pg = !req.params.pg || !Number(req.params.pg) || +req.params.pg < 0 ? null : +req.params.pg - 1;
if (!req.query.pageSize || !Number(req.query.pageSize || req.query.pageSize > 100)) {
req.query.pageSize = 100;
}
if (!Number(req.query.maxRecords || req.query.maxRecords == 0)) {
req.query.maxRecords = null;
}
if (req.query.maxRecords && +req.query.maxRecords < +req.query.pageSize) {
req.query.pageSize = req.query.maxRecords;
}
let modifiedAfter,
modifiedAfterDate,
createdAfter,
createdAfterDate,
likeYTID;
if (
req.query.modifiedAfter &&
typeof Date.parse(decodeURIComponent(req.query.modifiedAfter)) === 'number' &&
Date.parse(decodeURIComponent(req.query.modifiedAfter)) > 0
) {
modifiedAfter = Date.parse(decodeURIComponent(req.query.modifiedAfter));
modifiedAfterDate = new Date(modifiedAfter);
}
if (
req.query.createdAfter &&
typeof Date.parse(decodeURIComponent(req.query.createdAfter)) === 'number' &&
Date.parse(decodeURIComponent(req.query.createdAfter)) > 0
) {
createdAfter = Date.parse(decodeURIComponent(req.query.createdAfter));
createdAfterDate = new Date(createdAfter);
}
if (req.query.youTube && regexYT.test(decodeURIComponent(req.query.youTube))) likeYTID = regexYT.exec(decodeURIComponent(req.query.youTube))[1];
let queryText = req.params.pg !== null
? `for page ${req.params.pg + 1} (${req.query.pageSize} results per page)`
: `(${req.query.pageSize} results per page, ${req.query.maxRecords ? 'up to ' + req.query.maxRecords : 'for all'} results)`;
queryText += modifiedAfterDate ? ', modified after ' + modifiedAfterDate.toLocaleString() : '';
queryText += createdAfterDate ? ', created after ' + createdAfterDate.toLocaleString() : '';
queryText += likeYTID ? `, matching YouTube ID "${likeYTID}"` : '';
console.log(`Performing videos/query ${res ? 'external' : 'internal'} API request ${queryText}...`);
const cachePath = `.cache${req.url}.json`;
const cachedResult = cache.readCacheWithPath(cachePath);
if (cachedResult !== null) {
console.log(`Cache hit. Returning cached result for ${req.url}...`);
if (res) return res.status(200).send(JSON.stringify(cachedResult));
else return cachedResult;
} else {
console.log(`Cache miss. Loading from Airtable for ${req.url}...`);
let data = [],
pg = 0,
ps = +req.query.pageSize,
filterStrings = [],
options = {
pageSize: ps,
view: 'All Online Videos',
sort: [{ field: 'Modified', direction: 'desc' }]
};
if (formatFields.get(req.query.format)) options.fields = formatFields.get(req.query.format);
if (req.query.maxRecords && !req.params.pg) options.maxRecords = +req.query.maxRecords;
if (modifiedAfter) filterStrings.push(`IS_AFTER({Modified}, DATETIME_PARSE(${modifiedAfter}))`);
if (createdAfter) filterStrings.push(`IS_AFTER(CREATED_TIME(), DATETIME_PARSE(${createdAfter}))`);
if (likeYTID) filterStrings.push(`REGEX_MATCH({URL}, "${likeYTID}")`);
if (filterStrings.length > 0) options.filterByFormula = `AND(${filterStrings.join(',')})`;
rateLimiter.wrap(
base('Videos')
.select(options)
.eachPage(
function page(records, fetchNextPage) {
if (!req.params.pg || pg == req.params.pg) {
console.log(`Retrieving records ${pg * ps + 1}-${(pg + 1) * ps}...`);
data = [ ...data, ...records.map((record) => getFormat(req.query.format, videoFormat.toZoteroJSON)(record)) ];
if (pg == req.params.pg) {
console.log(`[DONE] Retrieved ${data.length} records.`);
cache.writeCacheWithPath(cachePath, data);
if (res) return res.status(200).send(JSON.stringify(data));
} else {
console.log(`Successfully retrieved ${records.length} records.`);
}
pg++, fetchNextPage();
} else {
pg++, fetchNextPage();
}
},
function done(err) {
if (err) {
console.error(err);
if (res) return res.status(400).send(JSON.stringify(err));
else throw new Error(err.message);
} else {
console.log(`[DONE] Retrieved ${data.length} records.`);
cache.writeCacheWithPath(cachePath, data);
if (res) return res.status(200).send(JSON.stringify(data));
}
}
)
);
if (!res) return data;
}
},
/**
* Retrieves a list of ESOVDB videos that are on YouTube by first checking the cache for a matching, fresh request, and otherwise performs an Airtable select() API query for 100 videos, sorted by oldest first, using Bottleneck for rate-limiting.
*
* @method queryYouTubeVideos
* @requires Airtable
* @requires Bottleneck
* @requires cache
* @param {!express:Request} req - Express.js HTTP request context, an enhanced version of Node's http.IncomingMessage class
* @param {string} [req.params.id] - URL parameter representing a YouTube video's URL, short URL, or video ID, passed last, as a required URL parameter. Either this or req.query.id is required.
* @param {string} [req.query.id] - URL query parameter representing a YouTube video's URL, short URL, or video ID, passed last, as a required URL parameter. Either this or req.params.id is required.
* @param {!express:Response} res - Express.js HTTP response context, an enhanced version of Node's http.ServerResponse class
* @sideEffects Queries the ESOVDB Airtable base, page by page, and either sends the retrieved data as JSON within an HTTPServerResponse object, or returns it as a JavaScript Object
* @returns {Object} Object with collection or properties for identifying and linking to an ESOVDB record on YouTube
*/
queryYouTubeVideos: (req, res) => {
let videoId;
if (req.params.id && regexYT.test(decodeURIComponent(req.params.id))) {
videoId = regexYT.exec(decodeURIComponent(req.params.id))[1];
} else if (req.query.id && regexYT.test(decodeURIComponent(req.query.id))) {
videoId = regexYT.exec(decodeURIComponent(req.query.id))[1];
} else {
if (res) {
return res.status(400).send('Missing parameter "id".');
} else {
throw new Error('Missing parameter "id".');
}
}
console.log(`Performing videos/youtube ${res ? 'external' : 'internal'} API request for YouTube ID "${videoId}"...`);
const cachePath = `.cache${req.url}.json`;
const cachedResult = cache.readCacheWithPath(cachePath);
if (cachedResult !== null) {
console.log(`Cache hit. Returning cached result for ${req.url}...`);
if (res) return res.status(200).send(JSON.stringify(cachedResult));
else return cachedResult;
} else {
console.log(`Cache miss. Loading from Airtable for ${req.url}...`);
let data = [],
options = {
pageSize: 1,
maxRecords: 1,
view: 'All Online Videos',
sort: [{ field: 'Created' }],
filterByFormula: `AND({Video Provider} = 'YouTube', REGEX_MATCH({URL}, "${videoId}"))`
};
options.fields = formatFields.get(req.query.format) ? formatFields.get(req.query.format) : formatFields.get('youtube');
rateLimiter.wrap(
base('Videos')
.select(options)
.eachPage(
function page(records, fetchNextPage) {
data = [ ...data, ...records.map((record) => getFormat(req.query.format, videoFormat.toYTJSON)(record)) ];
fetchNextPage();
},
function done(err) {
if (err) {
console.error(err);
if (res) res.status(400).end(JSON.stringify(err));
else throw new Error(err.message);
} else {
if (data.length > 0) {
console.log(`[DONE] Retrieved matching record.`);
cache.writeCacheWithPath(cachePath, data[0]);
if (res) return res.status(200).send(JSON.stringify(data[0]));
} else {
console.error(`[ERROR] Unable to find matching record.`);
if (res) return res.status(404).send('Unable to find matching record.');
}
}
}
)
);
if (!res && data.length > 0) return data[0];
}
},
/**
* Given a single ESOVDB Airtable record ID, returns that video's ESOVDB Airtable record data, in a neutral JSON format
*
* @method getVideoById
* @requires Airtable
* @requires Bottleneck
* @requires cache
* @param {!express:Request} req - Express.js HTTP request context, an enhanced version of Node's http.IncomingMessage class
* @param {string} [req.params.id] - A video's ESOVDB Airtable record ID, passed as a URL query parameter. Either this or req.query.id is required.
* @param {string} [req.query.id] - A video's ESOVDB Airtable record ID, passed as a URL query parameter. Either this or req.params.id is required.
* @param {!express:Response} res - Express.js HTTP response context, an enhanced version of Node's http.ServerResponse class
* @sideEffects Selects a single record from the ESOVDB Airtable base, and either sends the retrieved data as JSON within an HTTPServerResponse object, or returns it as a JavaScript Object
* @returns {Object} A JavaScript Object representing the entire Airtable record matching the specified video's ESOVDB Airtable record ID, with all of its fields
*/
getVideoById: (req, res) => {
const id = req.params.id || req.query.id || null;
if (id && /^rec[\w]{14}$/.test(id)) {
const cachePath = `.cache${req.url}.json`;
const cachedResult = cache.readCacheWithPath(cachePath);
if (cachedResult !== null) {
console.log(`Cache hit. Returning cached result for ${req.url}...`);
if (res) return res.status(200).send(JSON.stringify(cachedResult));
else return cachedResult;
} else {
console.log(`Cache miss. Loading from Airtable for ${req.url}...`);
try {
rateLimiter.wrap(
base('Videos')
.find(req.params.id, function(error, record) {
if (error) {
console.error(`[ERROR] Unable to find record "${id}".`);
if (res) return res.status(404).send('Unable to find matching record.');
else return;
} else {
const data = getFormat(req.query.format, videoFormat.toJSON)(record);
console.log(`[DONE] Retrieved record "${id}".`);
cache.writeCacheWithPath(cachePath, data);
if (res) return res.status(200).send(JSON.stringify(data));
else return data;
}
})
);
} catch (err) {
console.error(`[ERROR] ${err.message}.`);
if (res) return res.status(404).send(err.message);
else return;
}
}
} else {
console.error(`[ERROR] Invalid or no ESOVDB record ID specified.`);
if (res) return res.status(400).send('Invalid or no ESOVDB record ID specified.');
else return;
}
},
/**
* Updates one or more Airtable records using the non-destructive Airtable update() method, at most 10 at a time, until all provided records have been updated, using Bottleneck for rate-limiting.
*
* @method processUpdates
* @requires Airtable
* @requires Bottleneck
* @param {Object[]} items - An array of objects formatted as updates for Airtable (i.e. [ { id: 'recordId', fields: { 'Airtable Field': 'value', ... } }, ... ])
* @param {string} table - The name of a table in the ESOVDB (e.g., 'Videos', 'Series', etc)
* @returns {Object[]} The original array of video update objects, {@link videos}, passed to {@link processUpdates}
*/
processUpdates: (items, table) => {
let i = 0, updates = [ ...items ], queue = items.length;
while (updates.length) {
console.log(
`Updating record${updates.length === 1 ? '' : 's'} ${
i * 10 + 1
}${updates.length > 1 ? '-' : ''}${
updates.length > 1
? i * 10 +
(updates.length < 10
? updates.length
: 10)
: ''
} of ${queue} total...`
);
i++, rateLimiter.wrap(base(table).update(updates.splice(0, 10)));
}
return items;
},
/**
* Passes the body of an HTTP POST request to this server on to {@link processUpdates} for updating records on Airtable and sends a 200 server response with the array of objects originally passed to it in the [request body]{@link req.body}.
*
* @async
* @method updateTable
* @param {!express:Request} req - Express.js HTTP request context, an enhanced version of Node's http.IncomingMessage class
* @param {Object[]} req.body - An array of objects formatted as updates for Airtable (i.e. [ { id: 'recordId', fields: { 'Airtable Field': 'value', ... } }, ... ]) passed as the body of the [server request]{@link req}
* @param {!express:Response} res - Express.js HTTP response context, an enhanced version of Node's http.ServerResponse class
*/
updateTable: async (req, res) => {
if (req.body.length > 0) {
console.log(`Performing ${req.params.table}/update API request for ${req.body.length} record${req.body.length === 1 ? '' : 's'}...`);
const data = await module.exports.processUpdates(req.body, tables.get(req.params.table));
res.status(200).send(JSON.stringify(data));
}
},
/**
* Merges previously cached ESOVDB videos data (the vast majority of all videos in the DB) with videos modified in the past 24 hours.
*
* @async
* @method updateLatest
* @param {Boolean} [useCache=true] - Whether or not data on the 'latest' data (i.e. modifications to ESOVDB videos data made in the past 24 hours) should be pulled from the cache, or freshly retrieved from the ESOVDB Airtable
* @sideEffects Reads from and writes to (overwrites) a JSON file containing all video data in the ESOVDB with any modifications made in the past 24 hours
* @returns {Object[]} Returns all ESOVDB videos data with any (if there are any) modifications made in the past 24 hours
*/
updateLatest: async (useCache = true) => {
let result, lastTime = new Date(); lastTime.setHours(0); lastTime.setMinutes(0); lastTime.setSeconds(0); lastTime.setMilliseconds(0); lastTime.setDate(lastTime.getDate() - 1);
const modifiedAfter = encodeURIComponent(lastTime.toLocaleString());
const existing = cache.readCacheWithPath('.cache/v1/videos/query/all.json', false);
const cachedModified = useCache ? cache.readCacheWithPath('.cache/v1/videos/query/latest.json') : null;
const modified = cachedModified ? cachedModified : await module.exports.queryVideos({ url: '/v1/videos/query/latest', query: { modifiedAfter } });
await sleep(5);
if (modified.length > 0) {
result = [ ...existing.filter((e) => !modified.some((m) => m.recordId === e.recordId)), ...modified ].sort((a, b) => Date.parse(b.modified) - Date.parse(a.modified));
cache.writeCacheWithPath('.cache/v1/videos/query/all.json', result);
console.log('› Overwrote existing video data with modified videos and rewrote cache.');
} else {
result = existing;
console.log('› Retrieved existing video data, no new videos to cache.');
}
console.log(`[DONE] Successfully retrieved ${result.length} videos.`);
return result;
},
/**
* Passes the body of an HTTP POST request to this server on to {@link updateLatest}, which merges previously cached ESOVDB videos data (the vast majority of all videos in the DB) with videos modified in the past 24 hours.
*
* @async
* @method getLatest
* @param {!express:Request} req - Express.js HTTP request context, an enhanced version of Node's http.IncomingMessage class
* @param {Object[]} req.body - An array of objects formatted as updates for Airtable (i.e. [ { id: 'recordId', fields: { 'Airtable Field': 'value', ... } }, ... ]) passed as the body of the [server request]{@link req}
* @param {!express:Response} [res=false] - Express.js HTTP response context, an enhanced version of Node's http.ServerResponse class or Boolean false, by default, which allows the function to distinguish between external clients, which need to be sent an HTTPServerResponse object, and internal usage of the function, which need to return a value
* @sideEffects Overwrites a JSON file containing all video data in the ESOVDB with any modifications made in the past 24 hours. If {@link res} is provided, sends an HTTPServerResponse object to the requesting client
* @returns {Object[]} If {@link res} is not provided (i.e. internal consumption of this API method), returns all ESOVDB videos data with any modifications made in the past 24 hours
*/
getLatest: async (req, res = false) => {
try {
console.log(`Performing videos/all ${res ? 'external' : 'internal'} API request...`);
const latest = await module.exports.updateLatest(req.headers && req.headers['esovdb-no-cache'] && req.headers['esovdb-no-cache'] === process.env.ESOVDB_NO_CACHE ? false : true);
if (res) res.status(200).send(JSON.stringify(latest));
else return latest;
} catch (err) {
if (res) res.status(400).end(JSON.stringify(err));
else throw new Error(err.message);
}
},
newVideoSubmission: async (req, res) => {
try {
if (!regexYTVideoId.test(req.params.id)) return res.status(400).send('Invalid YouTube Video ID.');
const video = await getVideo(req.params.id);
const ip = (req.headers['x-forwarded-for'] || req.connection.remoteAddress || '').split(',')[0].trim();
rateLimiter.wrap(
base('Submissions').create({
'Title': video.title || '',
'URL': `https://youtu.be/${video.id}`,
'Description': video.description || '',
'Year': +video.year || null,
'Date': video.date || null,
'Running Time': +video.duration || null,
'Medium': 'Online Video',
'YouTube Channel Title': video.channel || '',
'YouTube Channel ID': video.channelId || '',
'Submission Source': 'Is YouTube Video on ESOVDB?',
'Submitted by': ip || ''
}, function(err, record) {
if (err) throw new Error(err);
console.log(`Successfully created new submission on ESOVDB for YouTube video "${video.title || 'Title Unknown'}" (https://youtu.be/${video.id}).`);
return res.status(200).send(JSON.stringify(video));
}));
} catch (err) {
res.status(400).end(JSON.stringify(err));
}
}
};