├── .nvmrc
├── docs
├── version
├── bmc.png
├── icon.ico
├── linux.png
├── favicon.ico
├── windows.png
├── contributors.json
├── style.css
├── apple.svg
├── 404.html
├── contributors.html
├── support.html
└── index.html
├── .npmrc
├── CODEOWNERS
├── .eslintignore
├── .github
├── FUNDING.yml
└── workflows
│ ├── pr-acceptence.yml
│ └── ci.yml
├── .gitignore
├── .prettierignore
├── .husky
└── pre-commit
├── src
├── public
│ ├── bmc.png
│ ├── icon.ico
│ ├── icon.icns
│ ├── style.css
│ ├── app.js
│ └── index.html
├── services
│ ├── constants.js
│ ├── exif.js
│ ├── fileServices.js
│ └── downloadServices.js
├── main.js
└── memoryDownloader.js
├── assets
└── memory-download-app.png
├── .prettierrc.jsonn
├── samconfig.toml
├── .eslintrc.json
├── README.md
├── CONTRIBUTING.md
├── package.json
└── template.yml
/.nvmrc:
--------------------------------------------------------------------------------
1 | v17.1.0
2 |
--------------------------------------------------------------------------------
/docs/version:
--------------------------------------------------------------------------------
1 | 1.5.9
2 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | git-tag-version=false
2 |
--------------------------------------------------------------------------------
/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @cal-overflow
2 |
3 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | out
3 | /.idea
4 | .github
5 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | custom: https://www.buymeacoffee.com/cal.overflow
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 |
3 | node_modules
4 |
5 | out
6 |
7 | /.idea
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | out
4 | /.idea
5 | .husky
6 | .github
7 |
--------------------------------------------------------------------------------
/docs/bmc.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cal-overflow/memory-download/HEAD/docs/bmc.png
--------------------------------------------------------------------------------
/docs/icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cal-overflow/memory-download/HEAD/docs/icon.ico
--------------------------------------------------------------------------------
/docs/linux.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cal-overflow/memory-download/HEAD/docs/linux.png
--------------------------------------------------------------------------------
/docs/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cal-overflow/memory-download/HEAD/docs/favicon.ico
--------------------------------------------------------------------------------
/docs/windows.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cal-overflow/memory-download/HEAD/docs/windows.png
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | npx lint-staged
5 |
--------------------------------------------------------------------------------
/src/public/bmc.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cal-overflow/memory-download/HEAD/src/public/bmc.png
--------------------------------------------------------------------------------
/src/public/icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cal-overflow/memory-download/HEAD/src/public/icon.ico
--------------------------------------------------------------------------------
/src/public/icon.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cal-overflow/memory-download/HEAD/src/public/icon.icns
--------------------------------------------------------------------------------
/assets/memory-download-app.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cal-overflow/memory-download/HEAD/assets/memory-download-app.png
--------------------------------------------------------------------------------
/.prettierrc.jsonn:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "singleQuote": true,
4 | "tabWidth": 2,
5 | "trailingComma": "none",
6 | }
7 |
--------------------------------------------------------------------------------
/samconfig.toml:
--------------------------------------------------------------------------------
1 | version = 0.1
2 |
3 | [default.deploy.parameters]
4 | stack_name = "downloadmysnapchatmemories-dot-com"
5 | confirm_changeset = false
6 | fail_on_empty_changeset = false
7 | capabilities = []
8 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es2021": true,
5 | "node": true
6 | },
7 | "extends": ["eslint:recommended", "prettier"],
8 | "parserOptions": {
9 | "ecmaVersion": "latest",
10 | "sourceType": "module"
11 | },
12 | "rules": {}
13 | }
14 |
--------------------------------------------------------------------------------
/src/services/constants.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | months: {
3 | "01": "January",
4 | "02": "February",
5 | "03": "March",
6 | "04": "April",
7 | "05": "May",
8 | "06": "June",
9 | "07": "July",
10 | "08": "August",
11 | "09": "September",
12 | 10: "October",
13 | 11: "November",
14 | 12: "December",
15 | },
16 | };
17 |
--------------------------------------------------------------------------------
/docs/contributors.json:
--------------------------------------------------------------------------------
1 | {
2 | "contributors": [
3 | {
4 | "name": "Christian Lisle",
5 | "role": "Project Maintainer",
6 | "links": {
7 | "GitHub": "https://github.com/cal-overflow",
8 | "Youtube": "https://www.youtube.com/channel/UCTfscxyX4CI9SnWdFqK4FJw",
9 | "Website": "https://www.cal-overflow.dev"
10 | }
11 | }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/docs/style.css:
--------------------------------------------------------------------------------
1 | .invert {
2 | -webkit-filter: invert(100%);
3 | filter: invert(100%);
4 | }
5 | .bg-snap-yellow {
6 | background-color: #fffc00;
7 | }
8 | tt {
9 | background-color: rgb(50, 50, 50);
10 | color: white;
11 | border-radius: 3px;
12 | padding: 2px;
13 | }
14 | .btn {
15 | border-radius: 3px;
16 | }
17 | .btn:hover,
18 | .btn:focus {
19 | outline: none;
20 | box-shadow: none;
21 | }
22 | .btn:hover {
23 | filter: drop-shadow(1px 1px 1px rgb(50, 50, 50));
24 | }
25 | a.disabled {
26 | pointer-events: none;
27 | cursor: default;
28 | }
29 |
30 | #contributorsContainer {
31 | max-width: 700px;
32 | margin: 0 auto;
33 | }
34 |
35 | .contributor {
36 | border-bottom: black 1px solid;
37 | }
38 |
--------------------------------------------------------------------------------
/src/public/style.css:
--------------------------------------------------------------------------------
1 | .invert {
2 | -webkit-filter: invert(100%);
3 | filter: invert(100%);
4 | }
5 | .bg-snap-yellow {
6 | background-color: #fffc00;
7 | }
8 | tt {
9 | background-color: rgb(50, 50, 50);
10 | color: white;
11 | border-radius: 3px;
12 | padding: 2px;
13 | }
14 | .btn {
15 | border-radius: 3px;
16 | }
17 | .btn:hover,
18 | .btn:focus {
19 | outline: none;
20 | box-shadow: none;
21 | }
22 | .btn:hover {
23 | filter: drop-shadow(1px 1px 1px rgb(50, 50, 50));
24 | }
25 | a.disabled {
26 | pointer-events: none;
27 | cursor: default;
28 | }
29 | #preview {
30 | height: 30vh;
31 | }
32 | #preview img,
33 | #preview video {
34 | cursor: zoom-in;
35 | }
36 | .min-w-33 {
37 | min-width: 33%;
38 | }
39 |
--------------------------------------------------------------------------------
/docs/apple.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
11 |
12 |
--------------------------------------------------------------------------------
/.github/workflows/pr-acceptence.yml:
--------------------------------------------------------------------------------
1 | name: Validate build
2 | on: [pull_request]
3 |
4 | jobs:
5 | build_on_linux:
6 | runs-on: ubuntu-latest
7 | steps:
8 | - uses: actions/checkout@v2
9 | - uses: actions/setup-node@master
10 | with:
11 | node-version: "17.1.0"
12 | - name: install dependencies
13 | run: npm install
14 | - name: build
15 | run: npm run make
16 |
17 | build_on_mac:
18 | runs-on: macos-latest
19 | steps:
20 | - uses: actions/checkout@v2
21 | - uses: actions/setup-node@master
22 | with:
23 | node-version: "17.1.0"
24 | - name: install dependencies
25 | run: npm install
26 | - name: build
27 | run: npm run make
28 |
29 | build_on_win:
30 | runs-on: windows-latest
31 | steps:
32 | - uses: actions/checkout@v2
33 | - uses: actions/setup-node@master
34 | with:
35 | node-version: "17.1.0"
36 | - name: install dependencies
37 | run: npm install
38 | - name: build
39 | run: npm run make
40 |
--------------------------------------------------------------------------------
/src/services/exif.js:
--------------------------------------------------------------------------------
1 | const { ExifTool } = require("exiftool-vendored");
2 | const { unlink } = require("fs/promises");
3 | const { extname } = require("path");
4 | const dayjs = require("dayjs");
5 |
6 | const exiftool = new ExifTool({});
7 |
8 | const updateExifData = async (
9 | fileName,
10 | creationDateTimeString,
11 | geolocationData
12 | ) => {
13 | const extension = extname(fileName);
14 | if (extension === ".mp4") {
15 | // mp4 files are not supported by exiftool
16 | return;
17 | }
18 | const exifFormattedDate = dayjs
19 | .utc(creationDateTimeString, "YYYY-MM-DD HH:mm:ss Z")
20 | .format("YYYY:MM:DD HH:mm:ss");
21 |
22 | await exiftool.write(fileName, {
23 | DateTimeOriginal: exifFormattedDate,
24 | GPSLatitude: geolocationData.latitude,
25 | GPSLongitude: geolocationData.longitude,
26 | GPSLatitudeRef: geolocationData.latitude > 0 ? "North" : "South",
27 | GPSLongitudeRef: geolocationData.longitude > 0 ? "East" : "West",
28 | });
29 |
30 | await unlink(`${fileName}_original`);
31 | };
32 |
33 | module.exports = {
34 | updateExifData,
35 | };
36 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Download Snapchat Memories
2 |
3 | Want a copy of all of your Snapchat memories? You're in the right place!
4 |
5 | This desktop application makes downloading all of your memories as simple as possible. The app downloads photos and videos in regular `jpg` and `mp4` formats.
6 |
7 | ### How to use
8 |
9 | Download the application: [www.downloadmysnapchatmemories.com](http://www.downloadmysnapchatmemories.com).
10 |
11 | Watch a video tutorial [here](https://youtu.be/0_1mJ3w5LaA).
12 |
13 | Read a guide [here](https://www.cal-overflow.dev/post/download-snapchat-memories).
14 |
15 |
20 |
21 | If you want to use the app without complications, please follow [this tutorial](https://youtu.be/0_1mJ3w5LaA).
22 |
23 | ## App development 🤓
24 |
25 | ### Running the app in development mode
26 |
27 |
28 |
29 | #### System requirements
30 |
31 | 1. [NodeJS](http://nodejs.org) (v17.1.0)
32 | 2. [npm](http://npmjs.com)
33 | 3. [nvm](http://nvm.sh/)
34 |
35 | Once the repository is cloned on your computer, navigate to the repository folder, `memory-download`.
36 |
37 | Ensure that you are using the correct version of `node` with `nvm`:
38 |
39 | ```bash
40 | nvm use
41 | ```
42 |
43 | Install the required node modules using `npm`:
44 |
45 | ```bash
46 | npm i
47 | ```
48 |
49 | Run the [electron](https://www.electronjs.org/) desktop application in development mode
50 |
51 | ```bash
52 | npm run dev
53 | ```
54 |
55 | An electron application will open in development mode. Follow the steps provided by the simple user-interface to download your Snapchat memories.
56 |
57 | ### Contributing
58 |
59 | See [the contributing document](/CONTRIBUTING.md) for information regarding contributions.
60 |
--------------------------------------------------------------------------------
/docs/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Download Snapchat Memories
5 |
6 |
10 |
11 |
12 |
13 |
14 |
20 |
21 |
25 |
26 |
27 |
28 |
29 |
30 |
Memory Download
31 |
Download all of your Snapchat memories with ease
32 |
33 |
34 |
38 |
404
39 |
The page you're looking for does not exist.
40 |
41 | You will redirected to the
42 | home page
45 | after 10 seconds.
46 |
47 |
48 |
49 |
50 |
65 |
66 |
67 |
68 |
73 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing 👥
2 |
3 |
4 |
5 | - [Tech stack](#tech-stack-%EF%B8%8F)
6 | - [Project structure](#project-structure-)
7 | - [`src/`](#src)
8 | - [`src/public/`](#srcpublic)
9 | - [`src/services/`](#srcservices)
10 | - [`docs/`](#docs)
11 | - [Requirements](#requirements-)
12 | - [Version bumping](#version-bumping)
13 | - [Creating a contributor entry](#create-a-contributor-entry-)
14 |
15 | ## Tech stack ⚛️
16 |
17 | This desktop application is built with [Electron](https://www.electronjs.org/). \
18 | [Bootstrap](https://getbootstrap.com/) is used for styling on both the [download website](http://www.downloadmysnapchatmemories.com) and the application itself.
19 |
20 | ## Project structure 📂
21 |
22 | ### `src/`
23 |
24 | The UI and brains of the application are containd in this directory.
25 |
26 | The application UI is defined within [`src/public`](#srcpublic). \
27 | The memory downloading logic is in [`src/memoryDownloader.js`](/src/memoryDownloader.js), which utilizes several services found in [`src/services`](#srcservices).
28 |
29 | ### `src/public/`
30 |
31 | Files in this directory are served as the static frontend of the electron application.
32 |
33 | ### `src/services/`
34 |
35 | Many of the helpful services (i.e., [download services](/src/services/downloadServices.js)) are contained in this directory.
36 |
37 | ### `docs/`
38 |
39 | Contents in this directory are served via the download website, [downloadmysnapchatmemories.com](http://www.downloadmysnapchatmemories.com).
40 |
41 | ## Requirements ✅
42 |
43 | When contributing, ensure you are working to close an issue on the repository. You can choose a pre-existing issue that is not being apparently worked on by anyone else. You may also create and request to be assigned to a new issue.
44 |
45 | ### Version bumping
46 |
47 | In order for contributions to be approved, they must include a version bump. This includes contributions that only make changes to the docs.
48 |
49 | To bump the app version, run the [npm version](https://docs.npmjs.com/cli/v8/commands/npm-version) command with the appropriate version increase.
50 |
51 | ```bash
52 | npm version
53 | ```
54 |
55 | Follow basic [semantic versioning](https://semver.org/#introduction) guidelines when incrementing the application version.
56 |
57 | ### Create a Contributor entry 🧑💻
58 |
59 | Contributor entries are JSON objects stored in array in `docs/contributors.json`. They must follow the pattern below.
60 |
61 | ```json
62 | {
63 | "name": "John Smith",
64 | "role": "Collaborator",
65 | "links": {
66 | "GitHub": "https://github.com/jsmith",
67 | "Youtube": "https://www.youtube.com/channel/johnsmith",
68 | "Website": "https://www.johnsmith.io"
69 | }
70 | }
71 | ```
72 |
73 | Contributor entries without a linked GitHub profile will **not** be accepted. \
74 | `Youtube` and `Website` links are optional.
75 |
--------------------------------------------------------------------------------
/src/services/fileServices.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 | const path = require("path");
3 | const constants = require("./constants.js");
4 | const { updateExifData } = require("./exif");
5 |
6 | const isDebugging = process.env.DEBUG_MODE;
7 | let outputDirectory;
8 |
9 | const getMemoryDataFromJSON = (inputFile) =>
10 | JSON.parse(fs.readFileSync(inputFile));
11 |
12 | const initializeEnvironment = (file, output) => {
13 | if (!fs.existsSync(file)) throw new Error(`JSON file "${file}" not found.`);
14 |
15 | outputDirectory = output;
16 | if (!fs.existsSync(outputDirectory)) fs.mkdirSync(outputDirectory);
17 | };
18 |
19 | const getFileName = async (memory, isConcatenatedVideo = false) => {
20 | const isPhoto = memory["Media Type"] === "Image";
21 | const extension = isPhoto ? "jpg" : "mp4";
22 | const year = memory["Date"].substring(0, 4);
23 | const month = constants.months[memory["Date"].substring(5, 7)];
24 | const day = memory["Date"].substring(8, 10);
25 | let fileName;
26 |
27 | if (!fs.existsSync(outputDirectory))
28 | throw new Error(`Output directory "${outputDirectory}" does not exist`);
29 |
30 | const parentDirectory = path.normalize(path.join(outputDirectory, year));
31 |
32 | if (!fs.existsSync(parentDirectory)) {
33 | fs.mkdirSync(parentDirectory);
34 | }
35 |
36 | fileName = `${month}-${day}${isPhoto || isConcatenatedVideo ? "" : "-short"}`;
37 |
38 | let i = 1;
39 | let confirmedFileName = fileName;
40 | while (
41 | fs.existsSync(
42 | path.normalize(
43 | path.join(parentDirectory, `${confirmedFileName}.${extension}`)
44 | )
45 | )
46 | ) {
47 | confirmedFileName = `${fileName}-${i++}`;
48 | }
49 |
50 | return path.normalize(
51 | path.join(parentDirectory, `${confirmedFileName}.${extension}`)
52 | );
53 | };
54 |
55 | const writeFile = async (file, data) => {
56 | const fileStream = fs.createWriteStream(file);
57 |
58 | await new Promise((resolve, reject) => {
59 | data.pipe(fileStream);
60 | data.on("error", reject);
61 | fileStream.on("finish", resolve);
62 | }).catch((err) => {
63 | if (isDebugging)
64 | console.log(
65 | `An error occurred with the file system. Error: ${err.message}`
66 | );
67 | });
68 | };
69 |
70 | const updateFileMetadata = (file, memory) => {
71 | const date = new Date(memory.Date);
72 | fs.utimes(file, date, date, () => {});
73 |
74 | // parse latitude and longitude 'x, y' from `Latitude, Longitude: x, y` string
75 | const geolocationString = memory.Location.split(": ")[1];
76 | const [latitude, longitude] = geolocationString.split(", ");
77 | const geolocationData = {
78 | latitude: parseFloat(latitude),
79 | longitude: parseFloat(longitude),
80 | };
81 |
82 | return updateExifData(file, memory.Date, geolocationData);
83 | };
84 |
85 | const getOutputInfo = () => {
86 | if (isDebugging) console.log("Getting output info");
87 |
88 | return {
89 | outputDirectory,
90 | message: `Your memories have been downloaded at:${outputDirectory} `,
91 | };
92 | };
93 |
94 | module.exports = {
95 | initializeEnvironment,
96 | getMemoryDataFromJSON,
97 | getFileName,
98 | writeFile,
99 | updateFileMetadata,
100 | getOutputInfo,
101 | };
102 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: Build and Deploy
2 | on:
3 | push:
4 | branches:
5 | - master
6 |
7 | env:
8 | AWS_REGION: us-east-1
9 | AWS_STACK_NAME: downloadmysnapchatmemories-dot-com
10 |
11 | jobs:
12 | create_tag:
13 | environment: production
14 | runs-on: ubuntu-latest
15 | steps:
16 | - uses: actions/checkout@v2
17 | - uses: actions/setup-node@master
18 | with:
19 | node-version: "17.1.0"
20 | - name: install dependencies
21 | run: npm install
22 |
23 | - name: Get version
24 | id: get-version
25 | run: echo ::set-output name=app_version::$(cat package.json | jq -r '.version')
26 |
27 | - name: Push tag
28 | run: |
29 | tag="v${{ steps.get-version.outputs.app_version }}"
30 | git config user.name "${GITHUB_ACTOR}"
31 | git config user.email "${GITHUB_ACTOR}@users.noreply.github.com"
32 | git tag "${tag}"
33 | git push origin "${tag}"
34 |
35 | publish_on_linux:
36 | needs: create_tag
37 | runs-on: ubuntu-latest
38 | steps:
39 | - uses: actions/checkout@v2
40 | - uses: actions/setup-node@master
41 | with:
42 | node-version: "17.1.0"
43 | - name: install dependencies
44 | run: npm install
45 | - name: publish
46 | env:
47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
48 | run: npm run publish
49 |
50 | publish_on_mac:
51 | needs: create_tag
52 | runs-on: macos-latest
53 | steps:
54 | - uses: actions/checkout@v2
55 | - uses: actions/setup-node@master
56 | with:
57 | node-version: "17.1.0"
58 | - name: install dependencies
59 | run: npm install
60 | - name: publish
61 | env:
62 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
63 | run: npm run publish
64 |
65 | publish_on_win:
66 | needs: create_tag
67 | runs-on: windows-latest
68 | steps:
69 | - uses: actions/checkout@v2
70 | - uses: actions/setup-node@master
71 | with:
72 | node-version: "17.1.0"
73 | - name: install dependencies
74 | run: npm install
75 | - name: publish
76 | env:
77 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
78 | run: npm run publish
79 |
80 | deploy_website:
81 | needs: [publish_on_linux, publish_on_mac, publish_on_win]
82 | runs-on: ubuntu-latest
83 | permissions:
84 | id-token: write
85 | contents: read
86 | steps:
87 | - name: Checkout 🛎
88 | uses: actions/checkout@master
89 |
90 | - name: Configure AWS Credentials
91 | uses: aws-actions/configure-aws-credentials@v1-node16
92 | with:
93 | role-to-assume: ${{ secrets.IAM_ROLE_ARN }}
94 | aws-region: ${{ env.AWS_REGION }}
95 |
96 | - name: Deploy CloudFormation stack
97 | run: sam deploy --config-file ./samconfig.toml --region $AWS_REGION --stack-name $AWS_STACK_NAME
98 |
99 | - name: Upload website content
100 | run: |
101 | BUCKET=$(aws cloudformation describe-stacks --stack-name=$AWS_STACK_NAME --region $AWS_REGION --query 'Stacks[0].Outputs[?OutputKey==`S3BucketName`].OutputValue' --output text)
102 | aws s3 sync docs s3://$BUCKET
103 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "memory-download",
3 | "version": "1.5.9",
4 | "description": "Desktop application for downloading your Snapchat memories.",
5 | "license": "UNLICENSED",
6 | "author": {
7 | "name": "Christan Lisle",
8 | "email": "lisleachristian@gmail.com",
9 | "url": "https://www.cal-overflow.dev"
10 | },
11 | "main": "src/main.js",
12 | "scripts": {
13 | "dev": "DEBUG_MODE=true electron-forge start",
14 | "make": "electron-forge make",
15 | "package": "electron-forge package",
16 | "publish": "electron-forge publish",
17 | "postversion": "echo $npm_package_version > docs/version && git add package.json package-lock.json docs/version && git commit -m \"Bump version to ${npm_package_version}\"",
18 | "prepare": "husky install",
19 | "lint": "eslint .",
20 | "lint:fix": "eslint . --fix",
21 | "prettier:check": "prettier check .",
22 | "prettier": "prettier --write ."
23 | },
24 | "dependencies": {
25 | "@ffmpeg-installer/ffmpeg": "^1.1.0",
26 | "dayjs": "^1.11.2",
27 | "exiftool-vendored": "^16.4.0",
28 | "fs": "^0.0.1-security",
29 | "node-fetch": "^2.6.1",
30 | "video-stitch": "^1.7.1"
31 | },
32 | "devDependencies": {
33 | "@electron-forge/cli": "^6.0.0-beta.63",
34 | "@electron-forge/maker-deb": "^6.0.0-beta.63",
35 | "@electron-forge/maker-rpm": "^6.0.0-beta.63",
36 | "@electron-forge/maker-squirrel": "^6.0.0-beta.63",
37 | "@electron-forge/maker-zip": "^6.0.0-beta.63",
38 | "@electron-forge/publisher-github": "^6.0.0-beta.63",
39 | "electron": "^16.0.7",
40 | "electron-squirrel-startup": "^1.0.0",
41 | "eslint": "^8.16.0",
42 | "eslint-config-prettier": "^8.5.0",
43 | "husky": "^8.0.1",
44 | "lint-staged": "^12.5.0",
45 | "prettier": "2.6.2"
46 | },
47 | "config": {
48 | "forge": {
49 | "packagerConfig": {
50 | "icon": "src/public/icon"
51 | },
52 | "makers": [
53 | {
54 | "name": "@electron-forge/maker-squirrel",
55 | "config": {
56 | "name": "memory-download"
57 | }
58 | },
59 | {
60 | "name": "@electron-forge/maker-zip",
61 | "platforms": [
62 | "darwin",
63 | "linux",
64 | "win32",
65 | "mas"
66 | ],
67 | "config": {
68 | "name": "memory-download"
69 | }
70 | },
71 | {
72 | "name": "@electron-forge/maker-deb",
73 | "config": {
74 | "name": "memory-download"
75 | }
76 | },
77 | {
78 | "name": "@electron-forge/maker-rpm",
79 | "config": {
80 | "name": "memory-download"
81 | }
82 | }
83 | ],
84 | "publishers": [
85 | {
86 | "name": "@electron-forge/publisher-github",
87 | "config": {
88 | "repository": {
89 | "owner": "ChristianLisle",
90 | "name": "memory-download"
91 | },
92 | "draft": false
93 | }
94 | }
95 | ]
96 | }
97 | },
98 | "lint-staged": {
99 | "**/*.js": [
100 | "eslint ."
101 | ],
102 | "**/*": [
103 | "prettier --write --ignore-unknown"
104 | ]
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/src/main.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 | const { app, BrowserWindow, shell, ipcMain, dialog } = require("electron");
3 | const { downloadMemories } = require("./memoryDownloader");
4 | const dayjs = require("dayjs");
5 | const utc = require("dayjs/plugin/utc");
6 |
7 | dayjs.extend(utc);
8 |
9 | const isDebugging = process.env.DEBUG_MODE;
10 | let isProcessingMemories = false;
11 |
12 | const createWindow = () => {
13 | const win = new BrowserWindow({
14 | width: 1024,
15 | height: 768,
16 | minWidth: 800,
17 | minHeight: 600,
18 | webPreferences: {
19 | nodeIntegration: true,
20 | contextIsolation: false,
21 | enableRemoteModule: true,
22 | },
23 | });
24 |
25 | win.loadFile("src/public/index.html");
26 |
27 | win.webContents.setWindowOpenHandler(({ url }) => {
28 | if (process.platform === "darwin" && "file://" === url.substring(0, 7)) {
29 | shell.showItemInFolder(url.substring(7, url.length));
30 | } else shell.openExternal(url);
31 |
32 | return { action: "deny" };
33 | });
34 |
35 | win.webContents.on("did-finish-load", () => {
36 | win.webContents.send("message", { version: app.getVersion() });
37 | });
38 |
39 | win.on("close", (event) => {
40 | if (isProcessingMemories) {
41 | const choice = dialog.showMessageBoxSync(win, {
42 | type: "question",
43 | buttons: ["Yes", "No"],
44 | defaultId: 1,
45 | title: "Confirm",
46 | message: "Are you sure you want to quit?",
47 | detail: "Your download will be interrupted.",
48 | });
49 |
50 | if (choice === 1) {
51 | event.preventDefault();
52 | }
53 | }
54 | });
55 |
56 | return win;
57 | };
58 |
59 | app.whenReady().then(() => {
60 | const window = createWindow();
61 |
62 | app.on("window-all-closed", app.quit);
63 |
64 | app.on("activate", () => {
65 | if (BrowserWindow.getAllWindows().length === 0) createWindow();
66 | });
67 |
68 | ipcMain.on("beginDownload", (event, { input, output, options }) => {
69 | if (isDebugging)
70 | console.log(
71 | `${input} selected as input\n${output} selected as download location`
72 | );
73 |
74 | window.webContents.send("message", { message: "Downloading memories" });
75 | isProcessingMemories = true;
76 |
77 | downloadMemories(input, output, options, sendMessage)
78 | .then(() => {
79 | if (isDebugging) console.log("Download complete");
80 | })
81 | .catch((err) => {
82 | if (isDebugging)
83 | console.log(
84 | `An error occurred while downloading memories. Error: ${err.message}`
85 | );
86 |
87 | window.webContents.send("message", {
88 | message:
89 | "An unknown error occurred while processing your memories. Please try again",
90 | error: err,
91 | });
92 | })
93 | .finally(() => {
94 | isProcessingMemories = false;
95 | });
96 | });
97 |
98 | ipcMain.on("chooseDownloadPath", () => {
99 | dialog
100 | .showOpenDialog({
101 | properties: ["openDirectory", "createDirectory"],
102 | buttonLabel: "Select",
103 | })
104 | .then((res) => {
105 | window.webContents.send("message", {
106 | downloadLocation: path.resolve(res.filePaths[0]),
107 | });
108 | });
109 | });
110 |
111 | ipcMain.on("reload", () => {
112 | if (isDebugging) console.log("Reloading the window");
113 |
114 | window.reload();
115 | });
116 |
117 | const sendMessage = (data) => window.webContents.send("message", data);
118 | });
119 |
--------------------------------------------------------------------------------
/template.yml:
--------------------------------------------------------------------------------
1 | AWSTemplateFormatVersion: 2010-09-09
2 | Description: "AWS resources for a static website with a custom domain"
3 |
4 | Parameters:
5 | Domain:
6 | Type: String
7 | Default: downloadmysnapchatmemories.com
8 |
9 | HostedZone:
10 | Type: String
11 | Default: downloadmysnapchatmemories.com.
12 | Description: The hosted zone in which the domain belongs
13 |
14 | CachePolicyId:
15 | Type: String
16 | Default: 658327ea-f89d-4fab-a63d-7e88639e58f6 # id for the caching optimized policy
17 | # See more policies at https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html#managed-cache-policies-list
18 |
19 |
20 | Resources:
21 | S3Bucket:
22 | Type: AWS::S3::Bucket
23 | Properties:
24 | WebsiteConfiguration:
25 | IndexDocument: "index.html"
26 | ErrorDocument: "index.html"
27 |
28 | S3BucketPolicy:
29 | Type: AWS::S3::BucketPolicy
30 | Properties:
31 | Bucket: !Ref S3Bucket
32 | PolicyDocument:
33 | Version: 2012-10-17
34 | Statement:
35 | - Sid: PublicReadForGetBucketObjects
36 | Action: [ 's3:GetObject', 's3:ListBucket' ]
37 | Effect: Allow
38 | Principal:
39 | Service: cloudfront.amazonaws.com
40 | Resource:
41 | - !Sub 'arn:aws:s3:::${S3Bucket}'
42 | - !Sub 'arn:aws:s3:::${S3Bucket}/*'
43 |
44 | CloudFrontOriginAccessControl:
45 | Type: AWS::CloudFront::OriginAccessControl
46 | Properties:
47 | OriginAccessControlConfig:
48 | Name: !Sub '${AWS::StackName}-origin-access-control'
49 | Description: !Sub 'Origin access control for ${S3Bucket.DomainName}'
50 | OriginAccessControlOriginType: s3
51 | SigningBehavior: always
52 | SigningProtocol: sigv4
53 |
54 | # Note that these ACM Certificate resources will be stuck in creation until the DNS validation is completed
55 | # https://docs.aws.amazon.com/acm/latest/userguide/dns-validation.html#setting-up-dns-validation
56 | ACMCertificate:
57 | Type: AWS::CertificateManager::Certificate
58 | Properties:
59 | DomainName: !Ref Domain
60 | SubjectAlternativeNames: [ !Sub '*.${Domain}' ]
61 | ValidationMethod: DNS
62 |
63 | CloudFrontDistribution:
64 | Type: AWS::CloudFront::Distribution
65 | Properties:
66 | DistributionConfig:
67 | Comment: !Sub 'For the website hosted in the ${S3Bucket} bucket.'
68 | Aliases:
69 | - !Ref Domain
70 | - !Sub 'www.${Domain}'
71 | DefaultCacheBehavior:
72 | ViewerProtocolPolicy: redirect-to-https
73 | AllowedMethods:
74 | - GET
75 | - HEAD
76 | - OPTIONS
77 | CachedMethods:
78 | - GET
79 | - HEAD
80 | - OPTIONS
81 | CachePolicyId: !Ref CachePolicyId
82 | TargetOriginId: !Sub 's3-origin-${S3Bucket}'
83 | Compress: true
84 | Enabled: true
85 | DefaultRootObject: index.html
86 | CustomErrorResponses:
87 | - ErrorCode: 404
88 | ResponseCode: 200
89 | ResponsePagePath: "/index.html"
90 | Origins:
91 | - DomainName: !GetAtt S3Bucket.DomainName
92 | Id: !Sub 's3-origin-${S3Bucket}'
93 | OriginAccessControlId: !GetAtt CloudFrontOriginAccessControl.Id
94 | S3OriginConfig: # Necessary for cloudfront to work with s3 even though OAI is not being used
95 | OriginAccessIdentity: ''
96 | ViewerCertificate:
97 | AcmCertificateArn: !Ref ACMCertificate
98 | MinimumProtocolVersion: TLSv1
99 | SslSupportMethod: sni-only
100 |
101 | DNSRecords:
102 | Type: AWS::Route53::RecordSetGroup
103 | Properties:
104 | Comment: DNS Record for pointing to CloudFront Distribution
105 | HostedZoneName: !Ref HostedZone
106 | RecordSets:
107 | - Name: !Ref Domain
108 | Type: A
109 | AliasTarget:
110 | DNSName: !GetAtt CloudFrontDistribution.DomainName
111 | HostedZoneId: Z2FDTNDATAQYW2 # Static hosted zone id when creating alias records to cloudfront distributions.
112 | - Name: !Sub 'www.${Domain}'
113 | Type: A
114 | AliasTarget:
115 | DNSName: !GetAtt CloudFrontDistribution.DomainName
116 | HostedZoneId: Z2FDTNDATAQYW2 # Static hosted zone id when creating alias records to cloudfront distributions.
117 |
118 |
119 | Outputs:
120 | S3BucketName:
121 | Value: !Ref S3Bucket
122 | Description: The name of the stacks S3 bucket where the static site is hosted.
123 |
124 | CloudFrontDistributionId:
125 | Value: !Ref CloudFrontDistribution
126 | Description: The Id of the CloudFront Distribution
127 |
128 |
--------------------------------------------------------------------------------
/docs/contributors.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Contributors | Download Snapchat Memories
5 |
6 |
10 |
11 |
12 |
13 |
14 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
Memory Download
27 |
Download all of your Snapchat memories with ease
28 |
29 |
30 |
31 |
Contributors
32 |
42 |
43 | Want to contribute to the project? Visit the
44 | GitHub repository .
49 |
50 |
51 |
52 |
53 |
84 |
85 |
86 |
87 |
147 |
152 |
--------------------------------------------------------------------------------
/docs/support.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Support | Download Snapchat Memories
5 |
6 |
10 |
11 |
12 |
13 |
14 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
Memory Download
27 |
Download all of your Snapchat memories with ease
28 |
29 |
30 |
31 |
Support
32 |
Frequently Asked Questions
33 |
34 |
My download seems frozen. What should I do? 🧊
35 |
36 | If you really think your download is frozen, restart the download
37 | with the "Show progress updates" (under advanced settings) enabled.
38 | This will show you exactly how many files have been downloaded at
39 | each step.
40 |
41 |
42 |
Why does it keep saying my input file is expired? 🌧
43 |
44 | This happens when ALL of the URL's found in the
45 | memories_history.json file return a bad response from
46 | Snapchat's servers. The memories_history.json file only
47 | contains valid URL's for approximately 7 days after downloading your
48 | data from Snapchat. To verify that your download file is still valid
49 | watch
50 | this video .
56 |
57 |
58 |
Why does it say some of my memories failed to download? 🤔
59 |
60 | Something went wrong when trying to fetch some of your files. The
61 | application attempted to download those memories twice, but failed
62 | both times. This is likely because something went wrong on
63 | Snapchat's side. When this happens the application shows you the
64 | time and type of memory so that you can manually save these files.
65 |
66 |
67 |
Does the application do anything with my data? 🔐
68 |
69 | Your data is only used for requesting your files from Snapchat's
70 | servers. Your data is used to communicate with Snapchat's servers
71 | directly and is not used in any other way.
72 |
73 |
74 |
Can I do this on my phone? 📱
75 |
No. The application only runs on computers.
76 |
77 |
Which version should I download if I'm using a Chromebook? 💻
78 |
79 | Download the linux version of the application. That is the most
80 | likely to work on Chrome OS.
81 |
82 |
83 |
84 |
85 |
86 | Have a question you don't see listed above or just want to provide
87 | some feedback? Feel free to reach out to the creator via the contact
88 | button below.
89 |
90 |
91 |
93 |
94 | Support creator
95 |
97 |
101 |
107 | Contact creator
108 |
110 |
111 |
112 |
113 |
144 |
145 |
146 |
147 |
152 |
--------------------------------------------------------------------------------
/src/services/downloadServices.js:
--------------------------------------------------------------------------------
1 | const fetch = require("node-fetch");
2 | const fs = require("fs");
3 | const ffmpeg = require("@ffmpeg-installer/ffmpeg");
4 | const videoStitch = require("video-stitch");
5 | const constants = require("./constants.js");
6 | const {
7 | writeFile,
8 | getFileName,
9 | updateFileMetadata,
10 | } = require("./fileServices.js");
11 |
12 | const videoConcat = videoStitch.concat;
13 | const isDebugging = process.env.DEBUG_MODE;
14 |
15 | const checkVideoClip = (prev, cur) => {
16 | if (prev["Media Type"] !== "Video" || cur["Media Type"] !== "Video")
17 | return false;
18 |
19 | if (prev.Date.substring(0, 13) !== prev.Date.substring(0, 13)) return false;
20 |
21 | const times = {
22 | prev: {
23 | hour: parseInt(prev.Date.substring(11, 13)),
24 | minute: parseInt(prev.Date.substring(14, 16)),
25 | second: parseInt(prev.Date.substring(17, 19)),
26 | },
27 | cur: {
28 | hour: parseInt(cur.Date.substring(11, 13)),
29 | minute: parseInt(cur.Date.substring(14, 16)),
30 | second: parseInt(cur.Date.substring(17, 19)),
31 | },
32 | };
33 |
34 | // Handles most cases, allowing for 30 second difference in recording times
35 | if (JSON.stringify(times.prev) === JSON.stringify(times.cur)) {
36 | return true;
37 | } else if (times.prev.hour == times.cur.hour) {
38 | if (times.prev.minute == times.cur.minute) {
39 | return Math.abs(times.prev.second - times.cur.second) <= 24;
40 | } else if (Math.abs(times.prev.minute - times.cur.minute) == 1) {
41 | return 48 < times.prev.second && times.cur.second < 12;
42 | } else return false;
43 | } else return times.prev.minute == 59 && times.cur.minute == 0;
44 | };
45 |
46 | const downloadPhotos = async (photos, failedMemories, sendMessage) => {
47 | const type = "photo";
48 | const date = {};
49 | for (let i = 0; i < photos.length; i++) {
50 | const photo = photos[i];
51 |
52 | const res = await fetch(photo["Download Link"], { method: "POST" }).catch(
53 | (e) => fetchErrorHandler(e, photo, failedMemories)
54 | );
55 | if (!res) continue;
56 |
57 | const url = await res.text();
58 | const download = await fetch(url).catch((e) =>
59 | fetchErrorHandler(e, photo, failedMemories)
60 | );
61 | if (!download) continue;
62 |
63 | const fileName = await getFileName(photo);
64 |
65 | await writeFile(fileName, download.body);
66 | await updateFileMetadata(fileName, photo);
67 |
68 | removeFailedMemory(photo, failedMemories);
69 |
70 | handleUpdateMessages({
71 | memory: photo,
72 | sendMessage,
73 | type,
74 | count: i + 1,
75 | date,
76 | file: fileName,
77 | total: photos.length,
78 | });
79 | }
80 | };
81 |
82 | const downloadVideos = async (videos, failedMemories, sendMessage) => {
83 | const type = "video";
84 | const date = {};
85 | let prevMemory, fileName, prevUrl, prevFileName;
86 | let clips = [];
87 |
88 | for (let i = 0; i < videos.length; i++) {
89 | const video = videos[i];
90 |
91 | const res = await fetch(video["Download Link"], { method: "POST" }).catch(
92 | (e) => fetchErrorHandler(e, video, failedMemories)
93 | );
94 | if (!res) continue;
95 |
96 | const url = await res.text();
97 | if (url === prevUrl) continue; // Ignore duplicate URLs
98 |
99 | const isContinuationClip = prevMemory
100 | ? checkVideoClip(prevMemory, video)
101 | : false;
102 |
103 | if (isContinuationClip) {
104 | clips.push({ fileName: prevFileName });
105 | } else if (clips.length) {
106 | clips.push({ fileName: prevFileName }); // Last clip was the final clip in this memory
107 |
108 | videoConcat({ ffmpeg_path: ffmpeg.path })
109 | .clips(clips)
110 | .output(await getFileName(prevMemory, true))
111 | .concat()
112 | .then(async (outputFile) => {
113 | await updateFileMetadata(outputFile, prevMemory);
114 |
115 | for (const clip of clips) fs.rmSync(clip.fileName);
116 | })
117 | .catch((err) => {
118 | sendMessage({
119 | message: `There was an issue combining ${clips.length} clips into a single video file.Don't worry! The video clips will be saved individually.`,
120 | smallError: err,
121 | });
122 |
123 | if (isDebugging) {
124 | if (err) {
125 | console.log(
126 | `An error occurred while trying to combine video clips. Error: ${err.message}`
127 | );
128 | } else
129 | console.log(
130 | `An unknown error occurred while trying to combine video clips`
131 | );
132 | }
133 | })
134 | .finally(() => (clips = []));
135 | }
136 |
137 | const download = await fetch(url).catch((e) =>
138 | fetchErrorHandler(e, video, failedMemories)
139 | );
140 | if (!download) continue;
141 |
142 | fileName = await getFileName(video);
143 |
144 | await writeFile(fileName, download.body);
145 | await updateFileMetadata(fileName, video);
146 |
147 | removeFailedMemory(video, failedMemories);
148 |
149 | handleUpdateMessages({
150 | memory: video,
151 | sendMessage,
152 | type,
153 | count: i + 1,
154 | date,
155 | total: videos.length,
156 | });
157 |
158 | prevUrl = url;
159 | prevMemory = video;
160 | prevFileName = fileName;
161 | }
162 | };
163 |
164 | const handleUpdateMessages = ({
165 | date,
166 | count,
167 | total,
168 | type,
169 | file,
170 | memory,
171 | sendMessage,
172 | }) => {
173 | let isSendingUpdateMessage =
174 | date.memoriesThisMonth % 10 === 0 || count === 1 || count === total;
175 |
176 | if (!date.month || date.month !== memory.Date.substring(5, 7)) {
177 | date.month = memory.Date.substring(5, 7);
178 | date.memoriesThisMonth = 1;
179 | isSendingUpdateMessage = true;
180 |
181 | if (!date.year || date.year !== memory.Date.substring(0, 4)) {
182 | date.year = memory.Date.substring(0, 4);
183 | }
184 | } else {
185 | date.memoriesThisMonth++;
186 | }
187 |
188 | if (isSendingUpdateMessage) {
189 | sendMessage({
190 | file,
191 | count,
192 | type,
193 | date: {
194 | year: date.year,
195 | month: constants.months[date.month],
196 | },
197 | total: count === 1 ? total : undefined,
198 | });
199 | }
200 | };
201 |
202 | const fetchErrorHandler = (err, memory, failedMemories) => {
203 | if (isDebugging)
204 | console.log(`There was an issue fetching a memory. Error: ${err.message}`);
205 |
206 | failedMemories.push(memory);
207 | };
208 |
209 | const removeFailedMemory = (memory, failedMemories) => {
210 | const index = failedMemories.findIndex(
211 | (failedMemory) => failedMemory["Download Link"] === memory["Download Link"]
212 | );
213 |
214 | if (index > -1) {
215 | failedMemories.splice(index, 1);
216 | }
217 | };
218 |
219 | module.exports = { downloadPhotos, downloadVideos };
220 |
--------------------------------------------------------------------------------
/docs/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Download Snapchat Memories
5 |
6 |
10 |
11 |
12 |
13 |
14 |
20 |
21 |
22 |
26 |
35 |
36 |
37 |
38 |
39 |
40 |
Memory Download
41 |
Download all of your Snapchat memories with ease
42 |
43 |
44 |
45 |
46 |
47 |
48 | Want a copy of all your Snapchat memories? You're in the right
49 | place!
50 |
51 | This application allows you to download a copy of your memories to
52 | your computer all at once.
53 |
54 |
55 |
56 |
57 |
Download the application for your platform
58 |
59 |
110 |
111 |
112 | Note This tool is not affiliated with Snap Inc.
113 |
114 |
115 |
116 |
117 |
118 |
119 |
149 |
150 |
151 |
152 | You must enable JavaScript to use all features of this website.
155 |
190 |
195 |
--------------------------------------------------------------------------------
/src/memoryDownloader.js:
--------------------------------------------------------------------------------
1 | const {
2 | downloadPhotos,
3 | downloadVideos,
4 | } = require("./services/downloadServices.js");
5 | const {
6 | initializeEnvironment,
7 | getMemoryDataFromJSON,
8 | getOutputInfo,
9 | } = require("./services/fileServices.js");
10 |
11 | const isDebugging = process.env.DEBUG_MODE;
12 |
13 | const failedMemories = {
14 | photos: [],
15 | videos: [],
16 | reAttempted: [],
17 | };
18 |
19 | const downloadMemories = async (
20 | filepath,
21 | outputDirectory,
22 | options,
23 | sendMessage
24 | ) => {
25 | initializeEnvironment(filepath, outputDirectory);
26 |
27 | const data = getMemoryDataFromJSON(filepath);
28 |
29 | if (!data["Saved Media"]) {
30 | sendMessage({
31 | error: new Error(
32 | 'Invalid memories_history.json file provided. No "Saved Media" found.'
33 | ),
34 | message:
35 | "Unable to parse the file you provided. Please try uploading the memories_history.json file again.",
36 | });
37 | return;
38 | }
39 |
40 | const memories = data["Saved Media"].reverse();
41 |
42 | if (options.concurrent) {
43 | const sortedMemories = {};
44 |
45 | for (let i = 0; i < memories.length; i++) {
46 | const year = memories[i].Date.substring(0, 4);
47 |
48 | if (!(year in sortedMemories)) {
49 | sortedMemories[year] = {
50 | photos: [],
51 | videos: [],
52 | };
53 |
54 | for (let j = 0; j < memories.length; j++) {
55 | const memory = memories[j];
56 |
57 | if (year === memory.Date.substring(0, 4)) {
58 | if (memory["Media Type"] === "Image") {
59 | sortedMemories[year].photos.push(memory);
60 | } else {
61 | sortedMemories[year].videos.push(memory);
62 | }
63 | }
64 | }
65 | }
66 | }
67 |
68 | const tasks = [];
69 | let photoCount = 0;
70 | let videoCount = 0;
71 | let total = 0;
72 |
73 | for (const year in sortedMemories) {
74 | if (sortedMemories[year].photos) {
75 | photoCount += sortedMemories[year].photos.length;
76 | tasks.push(
77 | downloadPhotos(
78 | sortedMemories[year].photos,
79 | failedMemories.photos,
80 | sendMessage
81 | )
82 | );
83 | }
84 | if (sortedMemories[year].videos) {
85 | videoCount += sortedMemories[year].videos.length;
86 | tasks.push(
87 | downloadVideos(
88 | sortedMemories[year].videos,
89 | failedMemories.videos,
90 | sendMessage
91 | )
92 | );
93 | }
94 | }
95 |
96 | total = photoCount + videoCount;
97 |
98 | if (isDebugging) console.log(`Processing ${total} memories`);
99 |
100 | sendMessage({
101 | photos: photoCount,
102 | videos: videoCount,
103 | totalMemories: total,
104 | message: "Downloading photos and videos",
105 | });
106 |
107 | await Promise.all(tasks);
108 |
109 | if (failedMemories.photos.length || failedMemories.videos.length) {
110 | if (
111 | !(
112 | failedMemories.photos.length === photoCount &&
113 | failedMemories.videos.length === videoCount
114 | )
115 | ) {
116 | const reAttemptTasks = [];
117 | if (isDebugging)
118 | console.log(
119 | `Re-attempting to download ${failedMemories.photos.length} photos and ${failedMemories.videos.length} videos`
120 | );
121 | sendMessage({ isReAttemptingFailedMemories: true });
122 |
123 | if (failedMemories.photos.length) {
124 | reAttemptTasks.push(
125 | downloadPhotos(
126 | failedMemories.photos,
127 | failedMemories.reAttempted,
128 | sendMessage
129 | )
130 | );
131 | }
132 | if (failedMemories.videos.length) {
133 | reAttemptTasks.push(
134 | downloadVideos(
135 | failedMemories.videos,
136 | failedMemories.reAttempted,
137 | sendMessage
138 | )
139 | );
140 | }
141 |
142 | sendMessage({
143 | message: `Re-attempting to download ${
144 | failedMemories.photos.length + failedMemories.videos.length
145 | } memories`,
146 | });
147 | await Promise.all(reAttemptTasks);
148 | } else {
149 | failedMemories.reAttempted = failedMemories.photos.concat(
150 | failedMemories.videos
151 | );
152 | }
153 | }
154 | } else {
155 | let photos = [];
156 | let videos = [];
157 | let total = 0;
158 |
159 | for (const memory of memories) {
160 | if (memory["Media Type"] === "Image") {
161 | photos.push(memory);
162 | } else {
163 | videos.push(memory);
164 | }
165 | }
166 |
167 | if (options.photos) total += photos.length;
168 | if (options.videos) total += videos.length;
169 |
170 | sendMessage({
171 | photos: photos.length,
172 | videos: videos.length,
173 | totalMemories: total,
174 | });
175 |
176 | if (isDebugging) console.log(`Processing ${total} memories`);
177 |
178 | if (options.photos) {
179 | sendMessage({ message: "Downloading photos" });
180 | await downloadPhotos(photos, failedMemories.photos, sendMessage);
181 | }
182 |
183 | if (options.videos) {
184 | sendMessage({ message: "Downloading videos" });
185 | await downloadVideos(videos, failedMemories.videos, sendMessage);
186 | }
187 |
188 | if (failedMemories.photos.length || failedMemories.videos.length) {
189 | if (
190 | !(
191 | failedMemories.photos.length === photos.length &&
192 | failedMemories.videos.length === videos.length
193 | )
194 | ) {
195 | if (isDebugging)
196 | console.log(
197 | `Re-attempting to download ${failedMemories.photos.length} photos and ${failedMemories.videos.length} videos`
198 | );
199 | sendMessage({ isReAttemptingFailedMemories: true });
200 |
201 | if (failedMemories.photos.length) {
202 | sendMessage({
203 | message: `Re-attempting to download ${failedMemories.photos.length} photos`,
204 | });
205 | await downloadPhotos(
206 | failedMemories.photos,
207 | failedMemories.reAttempted,
208 | sendMessage
209 | );
210 | }
211 |
212 | if (failedMemories.videos.length) {
213 | sendMessage({
214 | message: `Re-attempting to download ${failedMemories.videos.length} videos`,
215 | });
216 | await downloadVideos(
217 | failedMemories.videos,
218 | failedMemories.reAttempted,
219 | sendMessage
220 | );
221 | }
222 | } else {
223 | failedMemories.reAttempted = failedMemories.photos.concat(
224 | failedMemories.videos
225 | );
226 | }
227 | }
228 | }
229 |
230 | if (failedMemories.reAttempted.length) {
231 | sendMessage({ failedMemories: failedMemories.reAttempted });
232 | }
233 |
234 | const downloadInfo = await getOutputInfo();
235 |
236 | sendMessage({ isComplete: true, ...downloadInfo });
237 | };
238 |
239 | module.exports = { downloadMemories };
240 |
--------------------------------------------------------------------------------
/src/public/app.js:
--------------------------------------------------------------------------------
1 | const { ipcRenderer } = require("electron");
2 |
3 | const fileUpload = document.getElementById("file-input");
4 | const downloadLocationButton = document.getElementById(
5 | "choose-download-location-button"
6 | );
7 | const waitCard = document.getElementById("step-4");
8 | const progress = document.getElementById("percent");
9 | const progressBarNote = document.getElementById("progress-bar-note");
10 | const message = document.getElementById("message");
11 | const errorCard = document.getElementById("error-card");
12 | const errorText = document.getElementById("error-text");
13 | const expiredFileNote = document.getElementById("expired-file-note");
14 | const navButton = document.getElementById("nav-button");
15 | const progressBar = document.getElementById("progress-bar");
16 | const openMemories = document.getElementById("open-memories-button");
17 | const doneMessage = document.getElementById("done-message");
18 | const preview = document.getElementById("preview");
19 | const photoPreview = document.querySelector("#preview img");
20 |
21 | const extraOptionsButton = document.getElementById("extra-options-button");
22 | const extraOptions = document.getElementById("extra-options");
23 | const photosOption = document.querySelector('form [name="photos"]');
24 | const videosOption = document.querySelector('form [name="videos"]');
25 | const showPreviewsOption = document.querySelector('form [name="previews"]');
26 | const concurrentOption = document.querySelector('form [name="concurrent"]');
27 | const progressUpdatesOption = document.querySelector(
28 | 'form [name="progress-updates"]'
29 | );
30 |
31 | const feedbackLink = document.getElementById("feedback-link");
32 | const startOverLink = document.getElementById("start-over");
33 |
34 | const feedbackUrl = "http://www.cal-overflow.dev/contact?memoryDownload=true";
35 | let appVersion = undefined;
36 |
37 | let step = 0;
38 | let downloadLocation;
39 | let prevConcurrentSetting,
40 | prevShowProgressUpdatesSetting,
41 | prevShowPreviewsSetting;
42 | let isReAttemptingFailedMemories = false;
43 |
44 | const count = {
45 | allTime: {
46 | photos: 0,
47 | videos: 0,
48 | },
49 | };
50 | let total = 0;
51 | let failedMemories = [];
52 |
53 | ipcRenderer.on("message", (event, data) => {
54 | if (data.version) {
55 | appVersion = data.version;
56 | document.getElementById("version").innerHTML = data.version;
57 | }
58 |
59 | if (data.downloadLocation) {
60 | downloadLocation = data.downloadLocation;
61 | reEnableNavButton();
62 | }
63 |
64 | if (data.totalMemories) {
65 | progress.classList.remove("d-none");
66 |
67 | if (data.photos || data.videos) {
68 | feedbackLink.setAttribute(
69 | "href",
70 | `${feedbackUrl}&memoryTotal=${data.totalMemories}&photos=${data.photos}&videos=${data.videos}&version=${appVersion}`
71 | );
72 | }
73 |
74 | total = data.totalMemories;
75 | document.getElementById("total-memories").innerHTML = total;
76 | }
77 |
78 | if (data.total && !isReAttemptingFailedMemories) {
79 | if (data.date?.year) {
80 | if (!count[data.date.year]) {
81 | count[data.date.year] = {
82 | photos: 0,
83 | videos: 0,
84 | photoTotal: 0,
85 | videoTotal: 0,
86 | };
87 | }
88 | if (data.date?.year && data.type) {
89 | count[data.date.year][`${data.type}Total`] = data.total;
90 | total += data.total;
91 | }
92 | }
93 | }
94 |
95 | if (data.count && !isReAttemptingFailedMemories) {
96 | let currentCount = 0;
97 |
98 | if (concurrentOption.checked) {
99 | if (progressUpdatesOption.checked) {
100 | document
101 | .getElementById("advanced-progress-updates")
102 | .classList.remove("d-none");
103 |
104 | if (!document.getElementById(`year-${data.date.year}`)) {
105 | const row = document.createElement("tr");
106 | row.setAttribute("id", `year-${data.date.year}`);
107 |
108 | const year = document.createElement("td");
109 | year.innerHTML = data.date.year;
110 |
111 | const emptyPhoto = document.createElement("td");
112 | const emptyVideo = document.createElement("td");
113 | emptyPhoto.classList.add("photo");
114 | emptyVideo.classList.add("video");
115 | emptyPhoto.innerHTML = emptyVideo.innerHTML = "0 / 0";
116 |
117 | row.append(year, emptyPhoto, emptyVideo);
118 |
119 | const table = document.querySelector(
120 | "#advanced-progress-updates tbody"
121 | );
122 | const rows = table.children;
123 |
124 | if (rows.length) {
125 | for (let i = 0; i < rows.length; i++) {
126 | if (rows[i].id > `year-${data.date.year}`) {
127 | table.insertBefore(row, rows[i]);
128 | break;
129 | }
130 | if (i === rows.length - 1) {
131 | table.appendChild(row);
132 | }
133 | }
134 | } else table.appendChild(row);
135 | }
136 |
137 | document.querySelector(
138 | `#year-${data.date.year} .${data.type}`
139 | ).innerHTML = `${data.count} / ${
140 | count[data.date.year][`${data.type}Total`]
141 | }`;
142 | }
143 |
144 | if (data.type === "photo") {
145 | count[data.date.year].photos = data.count;
146 | } else if (data.type === "video") {
147 | count[data.date.year].videos = data.count;
148 | }
149 |
150 | for (const year in count) {
151 | currentCount += count[year].photos + count[year].videos;
152 | }
153 | } else {
154 | if (data.type === "photo") {
155 | count.allTime.photos = data.count;
156 | } else if (data.type === "video") {
157 | count.allTime.videos = data.count;
158 | }
159 |
160 | currentCount = count.allTime.photos + count.allTime.videos;
161 | }
162 |
163 | const percent = `${parseInt((currentCount / total) * 100)}%`;
164 | progressBar.style.width = percent;
165 | progress.innerHTML = percent;
166 | }
167 |
168 | if (data.error) {
169 | showErrorMessage(data);
170 | return;
171 | }
172 |
173 | if (data.isReAttemptingFailedMemories) {
174 | isReAttemptingFailedMemories = true;
175 | progressBarNote.innerHTML =
176 | "The progress bar does not update for re-attempted downloads ";
177 | progressBarNote.classList.remove("d-none");
178 | }
179 |
180 | if (data.failedMemories) {
181 | failedMemories = data.failedMemories;
182 | }
183 |
184 | if (data.message) {
185 | message.innerHTML = data.message;
186 | }
187 |
188 | if (data.file) {
189 | handlePreviewFile(data);
190 | }
191 |
192 | if (data.smallError) {
193 | document.querySelector("#small-error-feedback pre").innerHTML =
194 | data.smallError.message;
195 | document.getElementById("small-error-feedback").classList.remove("d-none");
196 | }
197 |
198 | if (data.isComplete) {
199 | if (total === failedMemories.length) {
200 | showErrorMessage({
201 | message:
202 | "Either there is no internet connection or the input file provided is expired Be sure to use data from your Snapchat account that was downloaded within the last 7 days",
203 | });
204 | expiredFileNote.classList.remove("d-none");
205 | } else handleDownloadComplete(data);
206 | }
207 | });
208 |
209 | const handleStepChange = (i) => {
210 | if (i == 0 || step == 5) ipcRenderer.send("reload");
211 | else if (i !== 4) {
212 | startOverLink.classList.remove("d-none");
213 | document.getElementById(`step-${step}`).classList.add("d-none");
214 |
215 | if (step !== 0)
216 | document.getElementById(`${step}`).classList.toggle("bg-light");
217 | if (step !== 0)
218 | document.getElementById(`${step}`).classList.toggle("text-dark");
219 |
220 | document.getElementById(`step-${i}`).classList.remove("d-none");
221 | document.getElementById(`${i}`).classList.toggle("bg-light");
222 | document.getElementById(`${i}`).classList.toggle("text-dark");
223 | if (i === 1 || i == 2) {
224 | navButton.innerHTML = "Continue";
225 | navButton.classList.remove("disabled");
226 | }
227 |
228 | if (i === 3) {
229 | navButton.innerHTML = "Begin download";
230 | navButton.classList.add("disabled");
231 | }
232 | if (i === 5) {
233 | navButton.innerHTML = "Download more memories";
234 | navButton.classList.remove("d-none");
235 | }
236 | step = i;
237 | } else if (fileUpload.value && downloadLocation) {
238 | if (step !== 0)
239 | document.getElementById(`${step}`).classList.toggle("bg-light");
240 | if (step !== 0)
241 | document.getElementById(`${step}`).classList.toggle("text-dark");
242 |
243 | document.getElementById(`step-${i}`).classList.remove("d-none");
244 | document.getElementById(`${i}`).classList.toggle("bg-light");
245 | document.getElementById(`${i}`).classList.toggle("text-dark");
246 |
247 | navButton.classList.add("d-none");
248 | document.getElementById(`step-3`).classList.toggle("d-none");
249 |
250 | ipcRenderer.send("beginDownload", {
251 | input: fileUpload.files[0].path,
252 | output: downloadLocation,
253 | options: {
254 | photos: photosOption.checked,
255 | videos: videosOption.checked,
256 | concurrent: concurrentOption.checked,
257 | },
258 | });
259 | document.getElementById("donation-link").classList.remove("d-none");
260 | startOverLink.classList.add("d-none");
261 | step = i;
262 | }
263 | };
264 |
265 | // eslint-disable-next-line no-unused-vars
266 | const manualStepChange = (i) => {
267 | if (!(step == 4 || i >= 4 || i == step)) {
268 | handleStepChange(i);
269 | }
270 | };
271 |
272 | const reEnableNavButton = () => {
273 | if (fileUpload.value && downloadLocation) {
274 | navButton.classList.remove("disabled");
275 | } else if (!navButton.classList.contains("disabled")) {
276 | navButton.classList.add("disabled");
277 | }
278 | };
279 |
280 | // eslint-disable-next-line no-unused-vars
281 | const updateOptions = (option) => {
282 | let isEnablingAdvancedOptions = false;
283 | let isDisablingAdvancedOptions = false;
284 |
285 | if (option === "photos") {
286 | if (photosOption.checked) {
287 | videosOption.removeAttribute("disabled");
288 | isEnablingAdvancedOptions = true;
289 | } else {
290 | videosOption.setAttribute("disabled", null);
291 | isDisablingAdvancedOptions = true;
292 | }
293 | } else if (option === "videos") {
294 | if (videosOption.checked) {
295 | photosOption.removeAttribute("disabled");
296 | isEnablingAdvancedOptions = photosOption.checked;
297 | } else {
298 | photosOption.setAttribute("disabled", null);
299 | isDisablingAdvancedOptions = true;
300 | }
301 | } else if (option === "concurrent") {
302 | if (concurrentOption.checked) {
303 | progressUpdatesOption.removeAttribute("disabled");
304 | progressUpdatesOption.checked = prevShowProgressUpdatesSetting;
305 | } else {
306 | prevShowProgressUpdatesSetting = progressUpdatesOption.checked;
307 | progressUpdatesOption.checked = false;
308 | progressUpdatesOption.setAttribute("disabled", null);
309 | }
310 | }
311 |
312 | if (isEnablingAdvancedOptions) {
313 | showPreviewsOption.removeAttribute("disabled");
314 |
315 | if (videosOption.checked) {
316 | concurrentOption.removeAttribute("disabled");
317 | progressUpdatesOption.removeAttribute("disabled");
318 |
319 | showPreviewsOption.checked = prevShowPreviewsSetting;
320 | concurrentOption.checked = prevConcurrentSetting;
321 | progressUpdatesOption.checked = prevShowProgressUpdatesSetting;
322 | }
323 | } else if (isDisablingAdvancedOptions) {
324 | prevShowPreviewsSetting = showPreviewsOption.checked;
325 | prevConcurrentSetting = concurrentOption.checked;
326 | prevShowProgressUpdatesSetting = progressUpdatesOption.checked;
327 |
328 | if (!photosOption.checked) {
329 | showPreviewsOption.checked = false;
330 | showPreviewsOption.setAttribute("disabled", null);
331 | }
332 |
333 | concurrentOption.checked = false;
334 | concurrentOption.setAttribute("disabled", null);
335 |
336 | progressUpdatesOption.checked = false;
337 | progressUpdatesOption.setAttribute("disabled", null);
338 | }
339 | };
340 |
341 | const handlePreviewFile = (data) => {
342 | if (data.type === "photo" && showPreviewsOption.checked) {
343 | preview.classList.remove("d-none");
344 | photoPreview.setAttribute("src", data.file);
345 | } else {
346 | preview.classList.add("d-none");
347 | }
348 | };
349 |
350 | const handleDownloadComplete = (data) => {
351 | waitCard.classList.add("d-none");
352 |
353 | handleStepChange(5);
354 |
355 | if (data.message) doneMessage.innerHTML = data.message;
356 |
357 | openMemories.addEventListener("click", () => {
358 | window.open(`file://${data.outputDirectory}`);
359 | });
360 |
361 | if (failedMemories.length) {
362 | const failedMemoriesContent = document.getElementById("failed-memories");
363 | const failedMemoriesList = document.createElement("ul");
364 | failedMemoriesList.classList.add("list-unstyled");
365 |
366 | const failedMemoriesDescription = document.createElement("p");
367 | failedMemoriesDescription.innerHTML = `${failedMemories.length} memories failed to download. Metadata of the failed memories are shown below.Why is this happening? This is likely an error that occurred when something went wrong gathering a memory from Snapchat. `;
368 |
369 | failedMemories.forEach((memory) => {
370 | const memoryInfo = document.createElement("li");
371 | memoryInfo.innerHTML = `${memory["Media Type"]} - ${memory["Date"]}`;
372 |
373 | failedMemoriesContent.append(memoryInfo);
374 | failedMemoriesList.append(memoryInfo);
375 | });
376 |
377 | failedMemoriesContent.append(failedMemoriesDescription);
378 | failedMemoriesContent.append(failedMemoriesList);
379 | }
380 | };
381 |
382 | const showErrorMessage = ({ message, error }) => {
383 | waitCard.classList.add("d-none");
384 |
385 | errorText.innerHTML = message;
386 |
387 | if (error) {
388 | document.querySelector("#extra-error-information pre").innerHTML =
389 | error.message;
390 | }
391 |
392 | errorCard.classList.remove("d-none");
393 | };
394 |
395 | navButton.addEventListener("click", () => {
396 | handleStepChange(step + 1);
397 | });
398 |
399 | fileUpload.addEventListener("change", reEnableNavButton);
400 |
401 | downloadLocationButton.addEventListener("click", (event) => {
402 | event.preventDefault();
403 | ipcRenderer.send("chooseDownloadPath");
404 | });
405 |
406 | extraOptionsButton.addEventListener("click", (event) => {
407 | event.preventDefault();
408 | extraOptions.classList.remove("d-none");
409 | extraOptionsButton.classList.add("d-none");
410 | });
411 |
412 | // eslint-disable-next-line no-unused-vars
413 | const togglePreviewZoom = () => {
414 | const isPreviewAlreadyLarge = preview.style.height === "75vh";
415 | const height = isPreviewAlreadyLarge ? "30vh" : "75vh";
416 | const cursor = isPreviewAlreadyLarge ? "zoom-in" : "zoom-out";
417 |
418 | preview.style.height = height;
419 | photoPreview.style.cursor = cursor;
420 | };
421 |
--------------------------------------------------------------------------------
/src/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Download Snapchat Memories
5 |
6 |
7 |
8 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
Memory Download
21 |
Download all of your Snapchat memories with ease
22 |
23 |
24 |
28 |
29 |
30 |
31 | Want a copy of all your Snapchat memories? You're in the right
32 | place!
33 |
34 | This application allows you to download a copy of your memories to
35 | your computer all at once. It takes 5 simple steps.
36 |
37 |
38 |
Steps
39 |
40 | Download your Snapchat data
41 | Find and uncompress (unzip) your Snapchat data
42 | Upload your memories_history.json file
43 | Wait while your memories are prepared for you
44 | Enjoy your memories!
45 |
46 |
47 |
48 |
49 |
Download your Snapchat data
50 |
51 |
52 |
Snapchat makes downloading your data very simple!
53 |
54 |
55 |
56 | Sign into your Snapchat account at
57 | accounts.snapchat.com
63 |
64 | Click My Data
65 |
66 | Click Submit Request at the bottom of the
67 | page
68 |
69 |
70 | Wait for an email from Snapchat with a link to your data
71 | archive. (This may take as long as a day)
72 |
73 |
74 | Follow the link found in your email and follow the given
75 | instructions to download your data
76 |
77 |
78 |
79 |
80 | Need more help downloading your data? Visit the official
82 | Download my Data
88 | Snapchat support page.
90 |
91 |
92 |
93 |
94 |
95 |
Find and uncompress (unzip) your Snapchat data
96 |
97 |
98 |
99 | Unzipping the file allows you access to the specific file needed
100 | to download your memories.
101 |
102 |
103 |
104 |
105 | Find the mydata~###.zip file downloaded in the
106 | previous step
107 |
108 | Unzip the file
109 |
110 |
111 |
112 | The ### represents the download number provided by
114 | Snapchat
116 |
117 |
118 |
119 |
120 |
121 |
Upload your memories_history.json file
122 |
123 |
263 |
264 |
265 |
266 |
Wait while your memories are prepared for you
267 |
268 |
269 |
270 |
275 |
276 |
277 |
278 |
281 |
282 |
283 | Year
284 | Photos
285 | Videos
286 |
287 |
288 |
289 |
290 |
291 |
292 |
293 |
294 |
Downloading your memories...
295 |
296 |
304 |
0%
305 |
306 |
307 |
308 |
309 | Processing 0 files
310 |
311 | Do not close this window
312 |
313 |
314 |
318 |
A small error occurred
319 |
No other information available
320 |
Note that your download will continue
321 |
322 |
323 |
324 |
325 |
326 |
Your download is complete
327 |
328 |
329 | View memories
330 |
331 |
332 |
333 |
334 |
335 |
336 |
Get Started
337 |
343 |
344 | Support creator
345 |
347 |
348 |
349 |
350 |
Uh oh
351 |
352 |
357 | Try again
358 |
359 |
360 |
369 |
370 |
371 | Don't think your file is expired? Follow
372 | this tutorial
375 | to check one of the file download URL's. Once you have tested
376 | the file, feel free to
377 | reach out to me
382 |
383 |
384 |
385 |
386 |
387 |
388 |
474 |
475 |
476 |
477 | You must enable JavaScript to use all features of this application.
480 |
481 |
486 |
495 |
--------------------------------------------------------------------------------