initial commit
This commit is contained in:
parent
54cab7a244
commit
25edae4808
54
README.md
54
README.md
@ -1,2 +1,56 @@
|
||||
# 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