feat: photo sync (iCloud + Amazon import), larger gear icon, Luma branding
- 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>
This commit is contained in:
1 parent
5ff7f38b85
commit
4ff5a6c8ca
9 files changed
+569
-17
No files matched your search
@@ -0,0 +1,134 @@
|
||||
#!/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()
|
||||
Reference in new issue
Block a user