#!/usr/bin/env python3 """ Chaine complete : ZIP Snapchat -> album dans la galerie du telephone. python snap_album.py --zip "C:\\...\\mydata.zip" python snap_album.py --zip "C:\\...\\mydata.zip" "C:\\...\\mydata-2.zip" Etapes enchainees automatiquement : 1. decompression du (ou des) ZIP dans un dossier commun 2. recuperation de toutes les Memories (via snap_memories.py) 3. detection du telephone branche (Android / iPhone) 4. livraison de l'album selon la plateforme Asymetrie assumee entre les deux plateformes : ANDROID : entierement automatique. Un dossier depose dans Pictures/ EST un album pour la galerie. Necessite adb + le debogage USB active. iOS : iOS n'autorise AUCUNE ecriture dans la photothèque depuis un PC (Windows ne voit DCIM qu'en lecture). Le script fait donc tout ce qui est automatisable — telechargement, mise en forme, copie dans iCloud Drive — et l'album final se cree en un tap sur le telephone via un raccourci fourni. Ce n'est pas une limite du script. """ import argparse import calendar import os import re import shutil import subprocess import sys import zipfile # Sans ca, nos prints sont bufferises alors que le sous-processus de # telechargement ecrit directement : les etapes s'afficheraient dans le desordre. try: sys.stdout.reconfigure(line_buffering=True) except AttributeError: pass HERE = os.path.dirname(os.path.abspath(__file__)) DOWNLOADER = os.path.join(HERE, "snap_memories.py") ANDROID_DEST = "/sdcard/Pictures" def run(cmd, **kw): return subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", **kw) def ps(script): """Execute un bout de PowerShell et renvoie stdout.""" r = run(["powershell", "-NoProfile", "-NonInteractive", "-Command", script]) return r.stdout or "" # -------------------------------------------------------------------------- # 1. Decompression # -------------------------------------------------------------------------- # Un export fait vite plusieurs Go. Ecrits dans un dossier synchronise, ils # partent aussi dans le cloud : ca sature l'upload et parfois le quota. CLOUD_HINTS = ("dropbox", "onedrive", "google drive", "icloud", "nextcloud") def warn_if_cloud(path, what): hit = next((h for h in CLOUD_HINTS if h in path.lower()), None) if hit: print(f" ! {what} est dans un dossier synchronise ({hit}) :") print(f" plusieurs Go vont y etre ecrits, donc televerses.") return bool(hit) def extract_zips(zip_paths, dest=None): """Decompresse toutes les parties d'un export dans UN seul dossier. Au-dela de quelques Go, Snapchat livre mydata~.zip, puis -2.zip, -3.zip. Seule la premiere partie contient l'index json/ ; les suivantes n'apportent que des medias. Il faut donc les reunir avant de traiter quoi que ce soit. """ first = os.path.abspath(zip_paths[0]) base = re.sub(r"-\d+$", "", os.path.splitext(os.path.basename(first))[0]) chosen = bool(dest) dest = os.path.abspath(dest) if dest else os.path.join(os.path.dirname(first), base + "_extrait") if not chosen and warn_if_cloud(dest, "Le dossier de decompression"): print(f" Pour ecrire ailleurs : --extrait \"D:\\{base}_extrait\"") os.makedirs(dest, exist_ok=True) for n, zp in enumerate(zip_paths, 1): part = f" [partie {n}/{len(zip_paths)}]" if len(zip_paths) > 1 else "" extract_zip(zp, dest, part) return dest def extract_zip(zip_path, dest, part=""): zip_path = os.path.abspath(zip_path) if not zipfile.is_zipfile(zip_path): sys.exit(f"Ce fichier n'est pas un ZIP valide : {zip_path}") # Un marqueur PAR archive : sinon la partie 2 serait sautee parce que la # partie 1 a deja marque le dossier comme decompresse. marker = os.path.join(dest, "." + os.path.basename(zip_path) + ".ok") if os.path.exists(marker): print(f"[1/4] Deja decompresse{part}: {os.path.basename(zip_path)}") return dest print(f"[1/4] Decompression{part} : {zip_path}") with zipfile.ZipFile(zip_path) as zf: members = zf.infolist() for i, info in enumerate(members, 1): path = zf.extract(info, dest) # zipfile ne restitue PAS l'horodatage des entrees. Or c'est lui qui # porte l'heure de prise de vue (en UTC) dans les exports recents : # sans ca, snap_memories.py perd l'heure et ne garde que le jour. if not info.is_dir(): ts = calendar.timegm(info.date_time + (0, 0, -1)) os.utime(path, (ts, ts)) if i % 200 == 0 or i == len(members): print(f" {i}/{len(members)} fichiers") open(marker, "w").close() return dest # -------------------------------------------------------------------------- # 2. Telechargement des medias # -------------------------------------------------------------------------- def download_memories(export_dir, staging, layout="flat", limit=0, workers=8): if not os.path.isfile(DOWNLOADER): sys.exit(f"snap_memories.py introuvable a cote de ce script ({HERE})") cmd = [sys.executable, DOWNLOADER, "--export", export_dir, "--out", staging, "--layout", layout, "--workers", str(workers)] if limit: cmd += ["--limit", str(limit)] print(f"[2/4] Telechargement : -> {staging}") # sortie en direct : le telechargement peut durer longtemps proc = subprocess.run(cmd) if proc.returncode != 0: sys.exit("Le telechargement a echoue. Corrige d'abord cette etape " "(voir _echecs.txt), puis relance : tout est reprenable.") files = [f for f in os.listdir(staging) if os.path.isfile(os.path.join(staging, f)) and not f.startswith("_")] return files # -------------------------------------------------------------------------- # 3. Detection du telephone # -------------------------------------------------------------------------- def find_adb(): found = shutil.which("adb") if found: return found guesses = [ os.path.expandvars(r"%LOCALAPPDATA%\Android\Sdk\platform-tools\adb.exe"), os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\WinGet\Links\adb.exe"), r"C:\platform-tools\adb.exe", ] return next((g for g in guesses if os.path.isfile(g)), None) def detect_phone(): """Renvoie ('android'|'ios'|None, description).""" adb = find_adb() if adb: out = run([adb, "devices"]).stdout devices = [l.split("\t")[0] for l in out.splitlines()[1:] if "\tdevice" in l] if devices: model = run([adb, "-s", devices[0], "shell", "getprop", "ro.product.model"]).stdout.strip() return "android", f"{model or 'Android'} (adb {devices[0]})" names = ps("Get-PnpDevice -PresentOnly -ErrorAction SilentlyContinue | " "Where-Object { $_.FriendlyName } | " "Select-Object -ExpandProperty FriendlyName") low = names.lower() if "iphone" in low or "apple mobile device" in low or "ipad" in low: return "ios", "iPhone/iPad detecte (USB)" for hint in ("mtp", "android", "galaxy", "pixel", "xiaomi", "oneplus", "redmi"): if hint in low: return "android", f"Android detecte (USB, indice: {hint})" return None, "aucun telephone detecte" # -------------------------------------------------------------------------- # 4a. Livraison Android # -------------------------------------------------------------------------- def deliver_android(staging, album, files): adb = find_adb() if not adb: print("\n adb est absent : c'est lui qui pousse les fichiers sur le telephone.") print(" Installe-le puis relance la meme commande :") print(" winget install Google.PlatformTools") return False remote = f"{ANDROID_DEST}/{album}" print(f" cible : {remote}") r = run([adb, "shell", "mkdir", "-p", f"'{remote}'"]) if r.returncode != 0 and "exists" not in (r.stderr or "").lower(): print(f" ! creation du dossier refusee : {r.stderr.strip()}") print(" Verifie que le debogage USB est active et l'autorisation acceptee.") return False push = subprocess.run([adb, "push", staging + os.sep + ".", remote]) if push.returncode != 0: print(" ! le transfert a echoue (debogage USB autorise sur le telephone ?)") return False # Faire apparaitre l'album tout de suite. Le broadcast est deprecie sur les # Android recents : c'est du best-effort, sinon l'ouverture de la galerie # declenche le scan de toute facon. run([adb, "shell", "am", "broadcast", "-a", "android.intent.action.MEDIA_SCANNER_SCAN_FILE", "-d", f"file://{remote}"]) print(f"\n OK : {len(files)} fichiers pousses dans {remote}") print(f" L'album \"{album}\" apparait dans la galerie.") print(" S'il n'apparait pas dans la seconde : ouvre la galerie, le scan se") print(" declenche a ce moment-la (certains Android ignorent le broadcast).") return True # -------------------------------------------------------------------------- # 4b. Livraison iOS # -------------------------------------------------------------------------- def icloud_drive_dir(): for cand in (os.path.expandvars(r"%USERPROFILE%\iCloudDrive"), os.path.expandvars(r"%USERPROFILE%\iCloud Drive")): if os.path.isdir(cand): return cand return None def deliver_ios(staging, album, files): guide = os.path.join(os.path.dirname(staging.rstrip(os.sep)), "ALBUM-iOS.txt") icloud = icloud_drive_dir() copied_to = None # Sur iOS, l'app Photos ne lit QUE l'EXIF : contrairement a Google Photos, # elle ignore le nom de fichier et la date de modification. Sans exiftool, # tout l'album serait date du jour de l'import. has_exif = shutil.which("exiftool") is not None if not has_exif: print("\n ATTENTION — dates : exiftool est absent.") print(" L'app Photos d'iOS ne lit que l'EXIF (elle ignore le nom de") print(" fichier). Sans EXIF, tes souvenirs seront tous dates du jour") print(" de l'import au lieu de leur vraie date.") print(" Fortement conseille AVANT de continuer :") print(" winget install OliverBetz.ExifTool") print(" puis relance cette commande : elle ecrira l'EXIF sur les") print(" fichiers deja telecharges, sans rien retelecharger.") if icloud: dest = os.path.join(icloud, album) os.makedirs(dest, exist_ok=True) print(f" copie vers iCloud Drive : {dest}") n = 0 for f in files: src, dst = os.path.join(staging, f), os.path.join(dest, f) if not os.path.exists(dst) or os.path.getsize(dst) != os.path.getsize(src): shutil.copy2(src, dst) n += 1 copied_to = dest print(f" {n} fichiers copies ({len(files) - n} deja presents)") print(" iCloud va les synchroniser : ils apparaitront dans l'app Fichiers.") else: print("\n iCloud pour Windows n'est pas installe (pas de dossier iCloudDrive).") print(" Le transfert vers l'iPhone doit passer par un dossier synchronise.") steps = f"""ALBUM "{album}" SUR IPHONE — {len(files)} fichiers {'=' * 60} Pourquoi ce n'est pas 100% automatique : iOS n'autorise aucune ecriture dans la photothèque depuis un PC. Windows ne voit le dossier DCIM de l'iPhone qu'en LECTURE. Aucun script Windows ne peut donc creer l'album directement — la derniere etape se fait sur le telephone. La seule alternative (synchro Apple Devices) exigerait de DESACTIVER iCloud Photos, ce qui est pire. ETAPE 1 — les fichiers sur le telephone {'-' * 60} """ if copied_to: steps += f"""Deja fait par le script : les fichiers sont dans {copied_to} Sur l'iPhone, ouvre l'app Fichiers > iCloud Drive > {album} (attends que la synchro soit finie : plus d'icone de nuage sur les fichiers) """ else: steps += """Copie le dossier suivant vers un espace visible depuis l'iPhone : {staging} Au choix : - installe iCloud pour Windows, puis relance ce script : il copiera tout seul - ou glisse le dossier dans OneDrive / Google Drive / Dropbox - ou envoie-le en Wi-Fi local avec LocalSend (gratuit, sans compte) """.replace("{staging}", staging) steps += f""" ETAPE 2 — creer l'album (une seule fois, ~1 min) {'-' * 60} Sur l'iPhone, app Raccourcis > + > ajoute ces 3 actions dans cet ordre : 1. "Obtenir le contenu du dossier" -> choisis le dossier {album} 2. "Repeter avec chaque element" -> englobe l'action 3 3. "Enregistrer dans l'album photo" -> Album : "Nouvel album" -> {album} Nomme le raccourci "Importer {album}", puis lance-le UNE fois. ETAPE 3 — verifier {'-' * 60} App Photos > Albums > "{album}" doit contenir {len(files)} elements. DATES — a lire {'-' * 60} """ + ("""EXIF present : les vraies dates de prise de vue sont ecrites dans les fichiers. L'app Photos les classera correctement dans ta photothèque. """ if has_exif else """/!\\ exiftool n'etait PAS installe quand les fichiers ont ete prepares. L'app Photos d'iOS ne lit QUE l'EXIF — elle ignore le nom du fichier et sa date de modification (contrairement a Google Photos). Resultat : tout l'album sera date d'AUJOURD'HUI au lieu des vraies dates. Pour corriger avant l'import : winget install OliverBetz.ExifTool puis relance la meme commande snap_album.py. Elle ecrira l'EXIF sur les fichiers deja telecharges (aucun retelechargement) et regenerera ce guide. """) + """ Quand l'album est OK, tu peux supprimer le dossier de transfert. """ with open(guide, "w", encoding="utf-8") as fh: fh.write(steps) print(f"\n {len(files)} fichiers prets. Reste a creer l'album sur l'iPhone :") print(f" app Raccourcis > + > 3 actions :") print(f" 1. Obtenir le contenu du dossier -> {album}") print(f" 2. Repeter avec chaque element") print(f" 3. Enregistrer dans l'album photo -> Nouvel album : {album}") print(f" puis lance-le une fois.") print(f"\n Marche a suivre detaillee : {guide}") return True # -------------------------------------------------------------------------- def main(): ap = argparse.ArgumentParser( description="ZIP Snapchat -> album dans la galerie du telephone") ap.add_argument("--zip", nargs="+", metavar="ZIP", help="le ou les .zip recus de Snapchat ; donne-les TOUS si " "l'export est arrive en plusieurs parties (-2.zip, -3.zip)") ap.add_argument("--export", help="dossier deja decompresse (au lieu de --zip)") ap.add_argument("--album", default="Snapchat Memories", help="nom de l'album") ap.add_argument("--extrait", metavar="DOSSIER", help="ou decompresser les archives (defaut : a cote du .zip)") ap.add_argument("--staging", help="dossier de travail des medias recuperes") ap.add_argument("--limit", type=int, default=0, help="ne traiter que N medias (test)") ap.add_argument("--workers", type=int, default=8) ap.add_argument("--target", choices=("auto", "android", "ios", "none"), default="auto", help="forcer la plateforme au lieu de detecter") args = ap.parse_args() if not args.zip and not args.export: ap.error("donne --zip (le fichier recu) ou --export (dossier deja decompresse)") export_dir = args.export or extract_zips(args.zip, args.extrait) if args.export: print(f"[1/4] Export fourni : {export_dir}") staging = args.staging or os.path.join( os.path.expanduser("~"), "Snapchat-Memories", args.album) warn_if_cloud(staging, "Le dossier de destination") os.makedirs(staging, exist_ok=True) files = download_memories(export_dir, staging, "flat", args.limit, args.workers) if not files: sys.exit("Aucun media telecharge : rien a envoyer. Verifie l'etape 2.") if args.target == "none": print(f"\n[3/4] Telephone : ignore (--target none)") print(f"[4/4] Termine : {len(files)} medias prets dans {staging}") return if args.target == "auto": kind, desc = detect_phone() else: kind, desc = args.target, f"force par --target {args.target}" print(f"\n[3/4] Telephone : {desc}") if kind is None: print("\n Branche le telephone en USB puis relance la commande :") print(" tout est deja telecharge, le script reprendra directement ici.") print(" Android : active aussi le debogage USB (Options developpeur).") print(f"\n En attendant, les {len(files)} medias sont prets dans :\n {staging}") return print(f"[4/4] Livraison ({kind})") if kind == "android": deliver_android(staging, args.album, files) else: deliver_ios(staging, args.album, files) if __name__ == "__main__": main()