-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.js
More file actions
418 lines (364 loc) · 14 KB
/
Copy pathapp.js
File metadata and controls
418 lines (364 loc) · 14 KB
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
import * as dotenv from "dotenv";
dotenv.config();
import {
createWriteStream,
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
} from "fs";
import { v4 } from "uuid";
import { program, Option } from "commander";
import { pipeline } from "node:stream";
import { promisify } from "node:util";
import { exiftool } from "exiftool-vendored";
program.addOption(
new Option("-u, --username <string>", "Please specify username")
.env("FAMLY_USERNAME")
.makeOptionMandatory()
);
program.addOption(
new Option("-p, --password <string>", "Please specify password")
.env("FAMLY_PASSWORD")
.makeOptionMandatory()
);
program.addOption(
new Option(
"-df, --download-folder <string>",
"Please specify download folder"
)
.env("FAMLY_DOWNLOAD_FOLDER")
.default("downloads/")
);
program.addOption(
new Option("--graphqlurl <string>", "Please specify download folder")
.env("FAMLY_GRAPHQL_URL")
.default("https://app.famly.co/graphql")
);
program.addOption(
new Option(
"-ds, --download-since <date>",
"Download all media posted after this date. Must be in a format accepted by the new Date() constructor. Takes priority over the --delta parameter"
).env("FAMLY_DOWNLOAD_SINCE")
);
program.addOption(
new Option(
"-d, --delta",
"Downloads all media posted after the date in the .delta file."
).env("FAMLY_DELTA")
);
program.addOption(
new Option(
"--disable-exif",
"Disables setting the exif dates of photos."
).env("FAMLY_DISABLE_EXIF")
);
program.addOption(
new Option("-v, --verbose", "Enables verbose logging").env("FAMLY_VERBOSE")
);
program.addOption(
new Option(
"-ht, --height-target <integer>",
"The height target to use when fetching feed items. Higher number = fewer requests."
)
.default(10000)
.env("FAMLY_HEIGHT_TARGET")
);
program.parse();
const options = program.opts();
const famlyDeltaFilename = ".famlydownloaderdelta";
const username = options.username;
const password = options.password;
const downloadFolder = options.downloadFolder;
const graphqlURL = options.graphqlurl;
const getDeltaDownloadDate = () => {
try {
return JSON.parse(readFileSync(famlyDeltaFilename, "utf8")).newestDownload;
} catch (err) {
return undefined;
}
};
// If downloadSince is set, use that date
// Otherwise, if delta is set, use that date
// If not, download everything again
const downloadMediaSince = new Date(
options.downloadSince ||
(options.delta === undefined ? undefined : getDeltaDownloadDate())
);
if (isNaN(downloadMediaSince)) {
console.log(`Download all feed items.`);
} else {
console.log(`Download all feed items created after: ${downloadMediaSince}`);
}
const disableExif = options.downloadSince;
const verbose = options.verbose;
const heightTarget = options.heightTarget;
if (verbose) {
console.log(`Settings: ${options}`);
}
try {
if (!existsSync(downloadFolder)) {
mkdirSync(downloadFolder);
}
} catch (err) {
console.error(`Could not create download folder: ${err}`);
}
const installationId = v4();
getData(username, password);
/**
* There are two types of media to download:
* - Media posted as normal feed items
* - Media posted in observations
* Feed items are easy as they are just part of the feed elements
* Observations are slightly more painful as we have to:
* 1. Get the observationId from the feed
* 2. Query graphql for the media URLs using the observationIds
*
*/
async function getData(username, password) {
// First log in and get an access token
const accessToken = await login(username, password);
if (verbose) {
console.log(`Access Token : ${accessToken}`);
}
// Get the feed. This will download the feed media and observation data
await getFeed(accessToken);
}
async function getObservations(accessToken, observationIds) {
if (verbose) {
console.log(`Downloading Observations IDs: ${observationIds}`);
}
// This is the graphql query to fetch all observations.
const query = `
"query ObservationsByIds($observationIds: [ObservationId!]!) { childDevelopment { observations( first: 2147483647 observationIds: $observationIds ignoreMissing: true ) { results { ...ObservationData __typename } __typename } __typename }}fragment ObservationData on Observation { ...ObservationDataWithNoComments __typename}fragment ObservationDataWithNoComments on Observation { children { id name institutionId __typename } id version feedItem { id __typename } status { state createdAt __typename } variant images { height width id secret { crop expires key path prefix __typename } __typename } video { ... on TranscodingVideo { id __typename } ... on TranscodedVideo { duration height id thumbnailUrl videoUrl width __typename } __typename } __typename}"`;
const payload = `
{
"operationName": "ObservationsByIds",
"variables": {
"observationIds":
${JSON.stringify(observationIds)}
},
"query": ${query}
}`;
const res = await fetch(graphqlURL, {
method: "POST",
body: payload,
headers: {
"Content-Type": "application/json",
"x-famly-accesstoken": accessToken,
"x-famly-installationid": installationId,
},
});
const json = await res.json();
json.data.childDevelopment.observations.results.forEach((el) => {
// Loop over the observations
const createdAt = el.status.createdAt;
el.images.forEach((image) => {
const { height, width } = image;
const { expires, key, path, prefix } = image.secret;
const url = `${prefix}/${key}/${width}x${height}/${path}?expires=${expires}`;
const fileName = path.substring(path.lastIndexOf("/") + 1);
const fullFileName = createdAt + "_" + fileName;
downloadFile(url, fullFileName).then((path) => {
setExifData(path, new Date(createdAt));
});
});
if (el.video != null) {
const url = el.video.videoUrl;
const removePathParam = url.split("?")[0];
const fileName = removePathParam.substring(
removePathParam.lastIndexOf("/") + 1
);
const fullFileName = createdAt + "_" + fileName;
downloadFile(url, fullFileName).then((path) => {
setExifData(path, new Date(createdAt));
});
}
});
}
async function getFeed(accessToken) {
let date = new Date();
let numItemsFromFeed = 1;
let newestDate = NaN;
while (numItemsFromFeed > 0) {
const { observationIds, oldestItem, newestItem, numItems } =
await getFeedItems(accessToken, date, newestDate);
numItemsFromFeed = numItems;
// Download the media from observations
if (observationIds.length > 0) {
await getObservations(accessToken, observationIds);
}
date = oldestItem;
newestDate = newestItem;
}
if (!isNaN(date)) {
writeFileSync(
famlyDeltaFilename,
JSON.stringify({ newestDownload: newestDate })
);
}
console.log(
`Downloads finished. Oldest feed item downloaded = ${date}. Application will close once setting EXIF data is complete. This can take a long time.`
);
}
async function getFeedItems(accessToken, olderThan, newestItem) {
let oldestItem = olderThan;
const dateStr = olderThan.toISOString().split(".")[0] + "+00:00";
const encodedDateStr = encodeURIComponent(dateStr);
const observationIds = [];
const feedURL = `https://app.famly.co/api/feed/feed/feed?olderThan=${encodedDateStr}&heightTarget=${heightTarget}`;
console.log(`Downloading feed items older than ${dateStr}. URL = ${feedURL}`);
const res = await fetch(feedURL, {
method: "GET",
headers: {
"Content-Type": "application/json",
"x-famly-accesstoken": accessToken,
"x-famly-installationid": installationId,
},
});
const json = await res.json();
//const numItems = json.feedItems.length;
let numItems = 0;
json.feedItems.forEach((feedItem) => {
const feedItemDate = new Date(feedItem.createdDate);
if (!isNaN(downloadMediaSince) && feedItemDate <= downloadMediaSince) {
return;
}
numItems++;
if (feedItemDate < oldestItem) {
oldestItem = feedItemDate;
}
if (isNaN(newestItem) || feedItemDate > newestItem) {
newestItem = feedItemDate;
}
if (feedItem.embed != null && feedItem.embed.observationId != null) {
// If the feedItem is an observation, just store the observationId
// We will need it later when we query for the observation data
observationIds.push(feedItem.embed.observationId);
} else {
// If it's a feed item,
// check if it has a video and if so download it
// then check if it has images and download them
if (feedItem.videos != null) {
const createdDate = feedItem.createdDate;
feedItem.videos.forEach((video) => {
const url = video.videoUrl;
const removePathParam = url.split("?")[0];
const fileName = removePathParam.substring(
removePathParam.lastIndexOf("/") + 1
);
const fullFileName = createdDate + "_" + fileName;
downloadFile(url, fullFileName).then((path) => {
setExifData(path, new Date(createdDate));
});
});
}
if (feedItem.images != null) {
// has at least one image
feedItem.images.forEach((image) => {
const imageId = image.imageId;
const prefix = image.prefix;
const height = image.height;
const width = image.width;
const key = image.key;
const createdAt = image.createdAt.date.replace(" ", "_");
// FIXME: Get the suffix from the data instead
let suffix = ".png";
if (key.includes(".jpg")) {
suffix = ".jpg";
} else if (key.includes(".png")) {
suffix = ".png";
} else {
console.log(
`Key doesn't contain .jpg or .png. Which file format? ${key}`
);
}
const url = `${prefix}/${width}x${height}/${key}`;
const fileName = createdAt + "_" + imageId + suffix;
downloadFile(url, fileName).then((path) => {
setExifData(path, new Date(image.createdAt.date));
});
});
}
if (feedItem.files != null) {
feedItem.files.forEach((file) => {
// Set the filename to original filename (minus file extension) + date + file extension.
// For example: Document.pdf
// Becomes: Document-2023-02-01.pdf
const fileName = file.filename;
const fileNameSplit = fileName.split(".");
const extension = fileNameSplit[fileNameSplit.length - 1];
const fullFileName = `${fileName.substring(
0,
fileName.length - (extension.length + 1)
)}_${feedItem.createdDate}.${extension}`;
const url = file.url;
downloadFile(url, fullFileName);
});
}
}
});
return { observationIds, oldestItem, newestItem, numItems };
}
/**
* Login to fetch an accessToken
* @param {string} username
* @param {string} password
* @returns {string} an accessToken
*/
async function login(username, password) {
const loginRequest = `
{
"operationName":"Authenticate",
"variables":{
"email":"${username}",
"password":"${password}",
"deviceId":null,
"legacy":true
},
"query":"mutation Authenticate($email: EmailAddress!, $password: Password!, $deviceId: DeviceId, $legacy: Boolean) { me { authenticateWithPassword(email: $email, password: $password, deviceId: $deviceId, legacy: $legacy) { ...AuthenticationResult __typename } __typename }}fragment AuthenticationResult on AuthenticationResult { status __typename ... on AuthenticationFailed { status errorDetails errorTitle __typename } ... on AuthenticationSucceeded { accessToken deviceId __typename } ... on AuthenticationChallenged { ...AuthChallenge __typename }}fragment AuthChallenge on AuthenticationChallenged { loginId deviceId expiresAt choices { context { ...UserContextFragment __typename } hmac requiresTwoFactor __typename } person { name { fullName __typename } profileImage { url __typename } __typename } __typename}fragment UserContextFragment on UserContext { id target { __typename ... on PersonContextTarget { person { name { fullName __typename } __typename } children { name { firstName fullName __typename } profileImage { url __typename } __typename } __typename } ... on InstitutionSet { title profileImage { url __typename } __typename } } __typename}"
}
`;
const res = await fetch(graphqlURL, {
method: "POST",
body: loginRequest,
headers: { "Content-Type": "application/json" },
});
const json = await res.json();
const accessToken = json.data.me.authenticateWithPassword.accessToken;
console.log(accessToken);
return accessToken;
}
/**
* Download a file to disk. Stores it in downloadFolder
* @param {String} url the URL to download
* @param {String} name the filename to use
*/
async function downloadFile(url, name) {
const path = downloadFolder + name;
const streamPipeline = promisify(pipeline);
const res = await fetch(url, {
method: "GET",
});
const writeStream = await streamPipeline(res.body, createWriteStream(path));
return path;
}
let numExifSet = 0;
/**
* Set dates to EXIF data on files
* @param {String} path the path of the file to set EXIF data on
* @param {Date} date The date to set
*/
async function setExifData(path, date) {
if (disableExif) return;
await exiftool.write(path, { AllDates: date.toISOString().split(".")[0] }, [
"-overwrite_original",
]);
numExifSet++;
if (numExifSet % 100 === 0) {
console.log(`EXIF Progress. ${numExifSet} files complete`);
}
if (verbose) {
console.log(`Exif for file completed: ${path}`);
}
}