- Settings: iCloud Photos section with icloudpd-backed sync — Apple ID, password, download path, cookie dir, live log output via SSE - Settings: Amazon Photos one-time import — path to extracted data export, Add & Index button wires it directly into Watch Folders - scripts/amazon_sync.py: stdlib-only downloader using Netscape cookie file - server: POST /api/sync/icloud, /api/sync/amazon, GET /api/sync/stream (SSE log streaming matching ingest stream pattern) - db: icloud/amazon setting keys; getSettings handles bool + string fields - Gear icon: fontSize 16→20, button 38×38, color #555→#888 - favicon.svg: 6-blade aperture icon in Luma blue - Browser tab title: shpm → Luma - Dockerfile: installs icloudpd via pip3, copies scripts/ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
135 lines
3.9 KiB
Python
135 lines
3.9 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Download new photos from Amazon Photos using browser-exported cookies.
|
|
|
|
Usage:
|
|
amazon_sync.py <cookies.txt> <dest_dir>
|
|
|
|
cookies.txt must be in Netscape/Mozilla format. Export it from your browser
|
|
using an extension like "Get cookies.txt LOCALLY" while logged into amazon.com.
|
|
"""
|
|
|
|
import sys
|
|
import json
|
|
import time
|
|
import pathlib
|
|
import http.cookiejar
|
|
import urllib.request
|
|
import urllib.parse
|
|
import urllib.error
|
|
|
|
HEADERS = {
|
|
"User-Agent": (
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
"Chrome/124.0.0.0 Safari/537.36"
|
|
),
|
|
"Accept": "application/json",
|
|
}
|
|
|
|
|
|
def build_opener(cookie_file):
|
|
jar = http.cookiejar.MozillaCookieJar()
|
|
try:
|
|
jar.load(cookie_file, ignore_discard=True, ignore_expires=True)
|
|
except FileNotFoundError:
|
|
print(f"Error: cookie file not found: {cookie_file}", file=sys.stderr, flush=True)
|
|
sys.exit(1)
|
|
return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
|
|
|
|
|
def api_get(opener, url):
|
|
req = urllib.request.Request(url, headers=HEADERS)
|
|
with opener.open(req, timeout=30) as r:
|
|
return json.loads(r.read().decode())
|
|
|
|
|
|
def list_photos(opener, offset=0, limit=200):
|
|
params = urllib.parse.urlencode({
|
|
"filters": "kind:PHOTOS and contentProperties.contentType:(image*)",
|
|
"sort": '["modifiedDate DESC"]',
|
|
"limit": limit,
|
|
"offset": offset,
|
|
"asset": "ALL",
|
|
"tempLink": "false",
|
|
})
|
|
return api_get(opener, f"https://www.amazon.com/drive/v1/nodes?{params}")
|
|
|
|
|
|
def download_node(opener, node_id, dest_path):
|
|
url = f"https://www.amazon.com/drive/v1/nodes/{node_id}/content"
|
|
req = urllib.request.Request(url, headers=HEADERS)
|
|
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with opener.open(req, timeout=120) as r, open(dest_path, "wb") as f:
|
|
while True:
|
|
chunk = r.read(65536)
|
|
if not chunk:
|
|
break
|
|
f.write(chunk)
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) < 3:
|
|
print("Usage: amazon_sync.py <cookies.txt> <dest_dir>", file=sys.stderr, flush=True)
|
|
sys.exit(1)
|
|
|
|
cookie_file = sys.argv[1]
|
|
dest_dir = pathlib.Path(sys.argv[2])
|
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
opener = build_opener(cookie_file)
|
|
downloaded = 0
|
|
skipped = 0
|
|
offset = 0
|
|
|
|
print(f"Destination: {dest_dir}", flush=True)
|
|
|
|
while True:
|
|
print(f"Fetching photo list (offset={offset})…", flush=True)
|
|
try:
|
|
data = list_photos(opener, offset=offset)
|
|
except urllib.error.HTTPError as e:
|
|
if e.code == 401:
|
|
print(
|
|
"Authentication failed. Re-export your cookies from a logged-in amazon.com session.",
|
|
file=sys.stderr, flush=True,
|
|
)
|
|
else:
|
|
print(f"HTTP {e.code}: {e.reason}", file=sys.stderr, flush=True)
|
|
sys.exit(1)
|
|
except Exception as e:
|
|
print(f"Error: {e}", file=sys.stderr, flush=True)
|
|
sys.exit(1)
|
|
|
|
nodes = data.get("data", [])
|
|
total_count = data.get("count", 0)
|
|
|
|
if not nodes:
|
|
break
|
|
|
|
for node in nodes:
|
|
name = node.get("name") or f"{node['id']}.jpg"
|
|
dest = dest_dir / name
|
|
if dest.exists():
|
|
skipped += 1
|
|
continue
|
|
print(f" Downloading {name}", flush=True)
|
|
try:
|
|
download_node(opener, node["id"], dest)
|
|
downloaded += 1
|
|
time.sleep(0.1)
|
|
except Exception as e:
|
|
print(f" Error: {e}", file=sys.stderr, flush=True)
|
|
|
|
offset += len(nodes)
|
|
if offset >= total_count:
|
|
break
|
|
|
|
time.sleep(0.5)
|
|
|
|
print(f"\nDone — {downloaded} downloaded, {skipped} already present.", flush=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|