initial commit
This commit is contained in:
parent
54cab7a244
commit
25edae4808
54
README.md
54
README.md
@ -1,2 +1,56 @@
|
|||||||
# snapchat-memories-downloader
|
# snapchat-memories-downloader
|
||||||
|
|
||||||
|
This script will download all your Snapchat memories in bulk, **including the timestamp and geolocation**.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|||||||
359
main.py
Normal file
359
main.py
Normal 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
2500
notebooks/make_df.ipynb
Normal file
File diff suppressed because it is too large
Load Diff
4271
notebooks/verify_metadata.ipynb
Normal file
4271
notebooks/verify_metadata.ipynb
Normal file
File diff suppressed because it is too large
Load Diff
8
requirements.txt
Normal file
8
requirements.txt
Normal 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
180
resources/html/faq.html
Normal file
File diff suppressed because one or more lines are too long
650
resources/html/memories_history.html
Normal file
650
resources/html/memories_history.html
Normal file
File diff suppressed because one or more lines are too long
180
resources/index.html
Normal file
180
resources/index.html
Normal file
File diff suppressed because one or more lines are too long
27815
resources/json/memories_history.json
Normal file
27815
resources/json/memories_history.json
Normal file
File diff suppressed because it is too large
Load Diff
34
scripts/setup_env.sh
Normal file
34
scripts/setup_env.sh
Normal 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
27813
temp/file_info.json
Normal file
File diff suppressed because it is too large
Load Diff
3711
temp/filemetadata.csv
Normal file
3711
temp/filemetadata.csv
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user