├── .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 |
16 | 17 | 18 | 19 |
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 |
36 |
40 |
41 |
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 | 97 | 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 |
60 | 71 | 82 | 93 | 94 |

95 | 96 | More download options 102 | 103 |

104 | 105 | 109 |
110 | 111 | 112 | Note This tool is not affiliated with Snap Inc. 113 | 114 |
115 |
116 |
117 |
118 | 119 | 149 | 150 | 151 | 152 | 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 |
  1. Download your Snapchat data
  2. 41 |
  3. Find and uncompress (unzip) your Snapchat data
  4. 42 |
  5. Upload your memories_history.json file
  6. 43 |
  7. Wait while your memories are prepared for you
  8. 44 |
  9. Enjoy your memories!
  10. 45 |
46 |
47 | 48 |
49 |

Download your Snapchat data

50 | 51 |
52 |

Snapchat makes downloading your data very simple!

53 | 54 |
    55 |
  1. 56 | Sign into your Snapchat account at 57 | accounts.snapchat.com 63 |
  2. 64 |
  3. Click My Data
  4. 65 |
  5. 66 | Click Submit Request at the bottom of the 67 | page 68 |
  6. 69 |
  7. 70 | Wait for an email from Snapchat with a link to your data 71 | archive. (This may take as long as a day) 72 |
  8. 73 |
  9. 74 | Follow the link found in your email and follow the given 75 | instructions to download your data 76 |
  10. 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 |
  1. 105 | Find the mydata~###.zip file downloaded in the 106 | previous step 107 |
  2. 108 |
  3. Unzip the file
  4. 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 |
124 |
    125 |
  1. 126 | Select the memories_history.json file that is 127 | included in the data downloaded from Snapchat. You can find 128 | the file within the json folder. 129 |
  2. 130 |
131 | 137 | 138 |
    139 |
  1. 140 | Select the folder where your memories will be downloaded. 141 |
  2. 142 |
143 |
144 | 150 |
151 |

152 | 153 | Additional settings 156 | 157 |

158 | 159 |
160 |
161 |
Memory type
162 | 168 | 169 | 170 |
171 | 172 | 178 | 179 |
180 | 181 |
182 |
Advanced options
183 | 184 | 190 | 209 | 210 |
211 | 212 | 218 | 237 | 238 |
239 | 240 | 241 | 260 |
261 |
262 |
263 |
264 | 265 |
266 |

Wait while your memories are prepared for you

267 |
268 |
269 |
270 | 275 |
276 | 277 |
278 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 |
YearPhotosVideos
291 |
292 |
293 | 294 |

Downloading your memories...

295 | 296 |
297 |
303 |
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 | 331 |

332 |
333 |
334 |
335 | 336 | 337 | 347 |
348 | 349 |
350 |

Uh oh

351 |

352 | 359 | 360 |
364 |
More information
365 |
366 |               No other information available
367 |             
368 |
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 | 480 | 481 | 486 | 495 | --------------------------------------------------------------------------------