initial commit

This commit is contained in:
Josh Flatt 2025-12-08 00:21:24 -06:00
parent 54cab7a244
commit 25edae4808
13 changed files with 67575 additions and 0 deletions

View File

@ -1,2 +1,56 @@
# snapchat-memories-downloader
This script will download all your Snapchat memories in bulk, **including the timestamp and geolocation**.
![demo](./demo.gif)
## Getting your Data
- Login to Snapchat: <https://accounts.snapchat.com/>
- Request your data: <https://accounts.snapchat.com/accounts/downloadmydata>
- Select the `Export your Memories` and `Export JSON Files` option and continue
![export configuration](https://github.com/user-attachments/assets/dfcdb6a0-e554-46e8-bdba-77fe41c88a03)
## Downloading your Memories
- Clone or [Download](https://github.com/ToTheMax/Snapchat-All-Memories-Downloader/archive/refs/heads/main.zip) this Repository
- Extract the zip-file received from Snapchat in the same folder
- Run the script:
- Requirements: Python3.10+
- Install the required packages:
```bash
pip install -r requirements.txt
```
- Run the script:
```python
python main.py
```
### Optional Arguments
```txt
usage: main.py [-h] [-o OUTPUT] [-c CONCURRENT] [--no-exif] [--no-skip-existing] json_file
Download Snapchat memories from data export
positional arguments:
json_file Path to memories_history.json
options:
-h, --help show this help message and exit
-o, --output OUTPUT Output directory
-c, --concurrent CONCURRENT
Max concurrent downloads
--no-exif Disable EXIF metadata
--no-skip-existing Re-download existing files
```
## Trouble Shooting
1. Make sure you get a fresh zip-file before running the script, links will expire over time
2. If you are missing the `memories_history.json` file, make sure you selected the right options in the export configuration
3. Still problems? please make a new [issue](https://github.com/ToTheMax/Snapchat-All-Memories-Downloader/issues)

BIN
demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

359
main.py Normal file
View File

@ -0,0 +1,359 @@
import argparse
import asyncio
import json
import os
import pytz
import re
import time
import subprocess
from datetime import datetime
from pathlib import Path
import exif
import httpx
from pydantic import BaseModel, Field, field_validator
from tqdm.asyncio import tqdm
class Memory(BaseModel):
date: datetime = Field(alias="Date")
download_link: str = Field(alias="Download Link")
location: str = Field(default="", alias="Location")
latitude: float | None = None
longitude: float | None = None
@field_validator("date", mode="before")
@classmethod
def parse_date(cls, v):
if isinstance(v, str):
return datetime.strptime(v, "%Y-%m-%d %H:%M:%S UTC")
return v
def model_post_init(self, __context):
if self.location and not self.latitude:
if match := re.search(r"([-\d.]+),\s*([-\d.]+)", self.location):
self.latitude = float(match.group(1))
self.longitude = float(match.group(2))
@property
def filename(self) -> str:
return self.date.strftime("%Y-%m-%d_%H-%M-%S")
class Stats(BaseModel):
downloaded: int = 0
skipped: int = 0
failed: int = 0
mb: float = 0
def load_memories(json_path: Path) -> list[Memory]:
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
return [Memory(**item) for item in data["Saved Media"]]
async def get_cdn_url(download_link: str) -> str:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
download_link,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
return response.text.strip()
def add_exif_data(image_path: Path, memory: Memory):
try:
with open(image_path, "rb") as f:
img = exif.Image(f)
utc = pytz.utc
# Use the 'America/Chicago' zone which handles both CST (-6) and CDT (-5) automatically
central_timezone = pytz.timezone('America/Chicago')
utc_dt = memory.date.replace(tzinfo=utc)
local_dt = utc_dt.astimezone(central_timezone)
dt_str = local_dt.strftime("%Y:%m:%d %H:%M:%S")
# dt_str = memory.date.strftime("%Y:%m:%d %H:%M:%S")
img.datetime_original = dt_str
img.datetime_digitized = dt_str
img.datetime = dt_str
if memory.latitude is not None and memory.longitude is not None:
# Convert decimal degrees to degrees, minutes, seconds
def decimal_to_dms(decimal):
degrees = int(abs(decimal))
minutes_decimal = (abs(decimal) - degrees) * 60
minutes = int(minutes_decimal)
seconds = (minutes_decimal - minutes) * 60
return (degrees, minutes, seconds)
lat_dms = decimal_to_dms(memory.latitude)
lon_dms = decimal_to_dms(memory.longitude)
img.gps_latitude = lat_dms
img.gps_latitude_ref = "N" if memory.latitude >= 0 else "S"
img.gps_longitude = lon_dms
img.gps_longitude_ref = "E" if memory.longitude >= 0 else "W"
with open(image_path, "wb") as f:
f.write(img.get_file())
except:
pass
def add_metadata_mp4(video_path: Path, memory: Memory):
"""
Adds metadata (Date and GPS) to MP4 files by calling ExifTool
via the subprocess module.
"""
try:
dt_str = memory.date.strftime("%Y:%m:%d %H:%M:%S")
command = [
"exiftool",
"-P", # Preserve file modification date
"-overwrite_original",
"-m", # Allow minor warnings
# QuickTime Date/Time tags
f"-QuickTime:DateTimeOriginal={dt_str}",
f"-QuickTime:CreateDate={dt_str}",
f"-QuickTime:ModifyDate={dt_str}",
# QuickTime TimeZone is often required for the date to be recognized
f"-QuickTime:TimeZone=+00:00",
# # Final path argument
# str(video_path)
]
# dt_str = memory.date.strftime("%Y:%m:%d %H:%M:%S")
# dt_str_utc = memory.date.strftime("%Y:%m:%d %H:%M:%S-06:00") # ISO format with UTC offset
# tz = "-06:00" # Central Time Zone offset
# # 1. Start with the base command, including date tags (ALWAYS written)
# command = [
# "exiftool",
# "-overwrite_original",
# ]
# # 2. Conditionally add GPS tags to the command list
# if memory.latitude is not None and memory.longitude is not None:
# lat = memory.latitude
# lon = memory.longitude
# coord_str = f"{lat} {lon}"
# command.extend([
# # Use the standard GPS tags instead of the specific QuickTime ones
# f"-Keys:GPSCoordinates={coord_str}",
# # Keep DateTimeOriginal and TimeZone, but ensure you also use the correct TimeZone tag
# f"-QuickTime:DateTimeOriginal={dt_str}",
# f"-QuickTime:TimeZone={tz}",
# ])
# # command.extend([
# # # Generic Composite Tag (Tries to create necessary atoms)
# # f"-GPSCoordinates={coord_str}",
# # # EXIF
# # f"-EXIF:GPSLatitude={lat}",
# # f"-EXIF:GPSLongitude={lon}",
# # f"-EXIF:GPSLatitudeRef={'N' if lat >= 0 else 'S'}",
# # f"-EXIF:GPSLongitudeRef={'E' if lon >= 0 else 'W'}",
# # # Highly prioritized XMP Tag (Often read first by Adobe/Google)
# # f"-XMP:GPSLatitude={lat}",
# # f"-XMP:GPSLongitude={lon}",
# # # QuickTime Location tags (Apple/iOS compatibility)
# # f"-QuickTime:LocationLatitude={lat}",
# # f"-QuickTime:LocationLongitude={lon}",
# # # # Generic GPS tags (For broad compatibility)
# # # f"-GPSLatitude={lat}",
# # # f"-GPSLongitude={lon}",
# # # f"-GPSLatitudeRef={'N' if lat >= 0 else 'S'}",
# # # f"-GPSLongitudeRef={'E' if lon >= 0 else 'W'}",
# # ])
# # command.extend([
# # # Generic Composite Tag (Tries to create necessary atoms)
# # # f"-GPSCoordinates={coord_str}",
# # # EXIF
# # f"-exif:gpslatitude={lat}",
# # f"-exif:gpslongitude={lon}",
# # # f"-gpslatituderef={'N' if lat >= 0 else 'S'}",
# # # f"-gpslongituderef={'E' if lon >= 0 else 'W'}",
# # # Highly prioritized XMP Tag (Often read first by Adobe/Google)
# # f"-xmp:gpslatitude={lat}",
# # f"-xmp:gpslongitude={lon}",
# # f"-gpslatituderef={'N' if lat >= 0 else 'S'}",
# # f"-gpslongituderef={'E' if lon >= 0 else 'W'}",
# # # QuickTime Location tags (Apple/iOS compatibility)
# # f"-QuickTime:LocationLatitude={lat}",
# # f"-QuickTime:LocationLongitude={lon}",
# # # # Generic GPS tags (For broad compatibility)
# # # f"-GPSLatitude={lat}",
# # # f"-GPSLongitude={lon}",
# # # f"-GPSLatitudeRef={'N' if lat >= 0 else 'S'}",
# # # f"-GPSLongitudeRef={'E' if lon >= 0 else 'W'}",
# # ])
# # command.extend(["-overwrite_original"])
# # 3. Add the target file path at the end
command.append(str(video_path))
# 4. Execute the command
# We use check=True to raise an error if ExifTool fails.
subprocess.run(
command,
check=True,
capture_output=True, # Captures stdout/stderr
text=True
)
except subprocess.CalledProcessError as e:
# Handle cases where ExifTool runs but returns an error (e.g., file corrupted)
print(f"\nError: ExifTool failed for {video_path.name}: {e.stderr.strip()}")
except FileNotFoundError:
# Handle case where the 'exiftool' executable cannot be found
print("\nError: ExifTool not found. Please install ExifTool and ensure it's in your system PATH.")
except Exception as e:
# Catch all other exceptions
print(f"\nWarning: Failed to add metadata to {video_path.name}: {e}")
pass
async def download_memory(
memory: Memory, output_dir: Path, add_exif: bool, semaphore: asyncio.Semaphore
) -> tuple[bool, int]:
async with semaphore:
try:
cdn_url = await get_cdn_url(memory.download_link)
ext = Path(cdn_url.split("?")[0]).suffix or ".jpg"
output_path = output_dir / f"{memory.filename}{ext}"
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
response = await client.get(cdn_url)
response.raise_for_status()
output_path.write_bytes(response.content)
timestamp = memory.date.timestamp()
os.utime(output_path, (timestamp, timestamp))
if add_exif and ext == ".jpg":
add_exif_data(output_path, memory)
elif add_exif and ext == ".mp4":
add_metadata_mp4(output_path, memory)
return True, len(response.content)
except Exception as e:
print(f"\nError: {e}")
return False, 0
async def download_all(
memories: list[Memory],
output_dir: Path,
max_concurrent: int,
add_exif: bool,
skip_existing: bool,
):
output_dir.mkdir(parents=True, exist_ok=True)
semaphore = asyncio.Semaphore(max_concurrent)
stats = Stats()
start_time = time.time()
to_download = []
for memory in memories:
jpg_path = output_dir / f"{memory.filename}.jpg"
mp4_path = output_dir / f"{memory.filename}.mp4"
if skip_existing and (jpg_path.exists() or mp4_path.exists()):
stats.skipped += 1
else:
to_download.append(memory)
if not to_download:
print("All files already downloaded!")
return
progress_bar = tqdm(
total=len(to_download),
desc="Downloading",
unit="file",
disable=False,
)
async def process_and_update(memory):
success, bytes_downloaded = await download_memory(
memory, output_dir, add_exif, semaphore
)
if success:
stats.downloaded += 1
else:
stats.failed += 1
stats.mb += bytes_downloaded / 1024 / 1024
elapsed = time.time() - start_time
mb_per_sec = (stats.mb) / elapsed if elapsed > 0 else 0
progress_bar.set_postfix({"MB/s": f"{mb_per_sec:.2f}"}, refresh=False)
progress_bar.update(1)
await asyncio.gather(*[process_and_update(m) for m in to_download])
progress_bar.close()
elapsed = time.time() - start_time
mb_total = stats.mb
mb_per_sec = mb_total / elapsed if elapsed > 0 else 0
print(
f"\n{'='*50}\nDownloaded: {stats.downloaded} ({mb_total:.1f} MB @ {mb_per_sec:.2f} MB/s) | Skipped: {stats.skipped} | Failed: {stats.failed}\n{'='*50}"
)
async def main():
parser = argparse.ArgumentParser(
description="Download Snapchat memories from data export"
)
parser.add_argument(
"json_file",
nargs="?",
default="json/memories_history.json",
help="Path to memories_history.json",
)
parser.add_argument(
"-o", "--output", default="./downloads", help="Output directory"
)
parser.add_argument(
"-c", "--concurrent", type=int, default=40, help="Max concurrent downloads"
)
parser.add_argument("--no-exif", action="store_true", help="Disable EXIF metadata")
parser.add_argument(
"--no-skip-existing", action="store_true", help="Re-download existing files"
)
args = parser.parse_args()
json_path = Path(args.json_file)
output_dir = Path(args.output)
memories = load_memories(json_path)
await download_all(
memories,
output_dir,
args.concurrent,
not args.no_exif,
not args.no_skip_existing,
)
if __name__ == "__main__":
asyncio.run(main())

2500
notebooks/make_df.ipynb Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

8
requirements.txt Normal file
View File

@ -0,0 +1,8 @@
exif>=1.6.1
httpx>=0.28.1
pydantic>=2.12.0
tqdm>=4.67.1
# For development
jupyter
pandas

180
resources/html/faq.html Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

180
resources/index.html Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

34
scripts/setup_env.sh Normal file
View File

@ -0,0 +1,34 @@
#!/bin/bash
# Exit immediately if a command exits with a non-zero status
set -e
# Create the virtual environment in the current directory
echo "Checking for existing virtual environment..."
if [ -d "venv" ]; then
echo "Virtual environment already exists."
else
echo "Creating virtual environment..."
python3 -m venv .venv
echo "Virtual environment created."
fi
# Activate the virtual environment
echo "Activating virtual environment..."
source .venv/bin/activate
echo "Virtual environment activated."
# Upgrade pip to the latest version
echo "Upgrading pip..."
pip install --upgrade pip
echo "Pip upgraded."
# Install required packages from requirements.txt
if [ -f "requirements.txt" ]; then
echo "Installing required packages from requirements.txt..."
pip install -r requirements.txt
echo "Required packages installed."
else
echo "No requirements.txt file found. Skipping package installation."
fi
echo "Setup complete."

27813
temp/file_info.json Normal file

File diff suppressed because it is too large Load Diff

3711
temp/filemetadata.csv Normal file

File diff suppressed because it is too large Load Diff