Skip to content

Latest commit

 

History

History
340 lines (246 loc) · 13.4 KB

File metadata and controls

340 lines (246 loc) · 13.4 KB

iPhone Photo Manager (Legacy Sync)

Ein spezialisiertes Python-Tool zur Reorganisation alter iPhone-Foto-Backups. Dieses Skript löst das Problem von rekursiven Ordnerstrukturen und kollidierenden Dateinamen (IMG_0001.JPG), indem es alle Medien chronologisch sortiert, global nummeriert und in einer flachen Struktur archiviert.

📋 Das Problem

Alte iPhone-Backups (z. B. aus der Ära vor iCloud) speichern Bilder oft in unzähligen Unterordnern (100APPLE, 101APPLE etc.).

  • In jedem Ordner beginnt der Dateicounter erneut bei 0001.

  • Ein einfaches Zusammenführen führt zu massiven Namenskollisionen.

  • Dateien ohne EXIF-Metadaten (z. B. WhatsApp-Bilder) unterbrechen die chronologische Anzeige in Programmen wie XnView MP.

✨ Die Lösung

Dieses Skript analysiert den gesamten Bestand über eine lokale Datenbank, sortiert alle Medien (Bilder & Videos) sekundengenau und exportiert sie mit einem standardisierten, web-konformen Dateinamen. Sorgenkinder ohne Metadaten werden durch ein spezifisches Anker-Datum (09.09.1971) wieder sortierbar gemacht.

🛠️ Anforderungen & Installation

Das Skript benötigt Python 3 und das Tool ExifTool, um Apple-spezifische Metadaten (HEIC, MOV, QuickTime) zu verarbeiten.

ExifTool unter Linux (Zorin OS/Ubuntu) installieren:

Bash

sudo apt update && sudo apt install libimage-exiftool-perl -y

⚙️ Voraussetzungen für die Konvertierung

Für die Umwandlung von HEIC- (Apple) und anderen Bildformaten müssen folgende Python-Bibliotheken installiert sein:

bash

# Installation unter Linux (Zorin OS)
pip3 install Pillow pillow-heif --break-system-packages

📁 Projektstruktur

Das Skript arbeitet mit folgenden Verzeichnissen:

Verzeichnis Zweck
/fotopool-alt Quellverzeichnis mit der ungeordneten iPhone-Struktur.
/fotopool-bin Speicherort des Skripts und der Datenbank (photo_db.json).
/fotopool-neu Zielordner für alle erfolgreich datierten Medien.
/fotopool-ohne-datum Zielordner für Medien mit dem Anker-Datum 1971.

🚀 Bedienung

Das Skript wird in zwei Phasen über das Terminal gesteuert:

Phase 1: Analyse (Scan)

Erstellt eine Datenbank aller vorhandenen Medien und extrahiert die Zeitstempel.

Bash

python3 iphone-photo-manager.py --scan

Phase 2: Reorganisation (Export)

Es gibt drei Möglichkeiten, den Export durchzuführen:

  • Standard (PIC/VID): Unterscheidet automatisch zwischen Bildern und Videos.

    Bash

    python3 iphone-photo-manager.py --export
    
  • Neutral (OBJ): Verwendet ein einheitliches Label für alle Objekte.

    Bash

    python3 iphone-photo-manager.py --export --nameit OBJ
    
  • Individuell: Verwendet ein vom Nutzer definiertes Label.

    Bash

    python3 iphone-photo-manager.py --export --nameit MEINNAME
    

🖼️ Optionale JPG-Konvertierung

Das Skript ermöglicht es, alle Bildformate (HEIC, PNG, BMP, TIFF) während des Exports in das platzsparende JPEG-Format umzuwandeln. Dabei werden alle Metadaten (EXIF/IPTC/XMP) vom Original übernommen.

Nutze dazu einen der folgenden Parameter:

  • --extjpg10: Maximale Qualität (Qualitätsfaktor 95), nahezu verlustfrei
  • --extjpg7 : Ausgewogene Kompression (Qualitätsfaktor 75) – idealer Kompromiss aus Grösse und Qualität
  • --extjpg5 : Starke Kompression (Qualitätsfaktor 50) für minimale Dateigrössen

Beispiel:

Bash

python3 iphone-photo-manager.py --export --extjpg5 --nameit OBJ

Hinweis: Videos bleiben bei diesem Vorgang in ihrem Original-Container (z. B. .mov oder .mp4) erhalten.

  • Prozess: Angezeigt mit Fortschrittsbalken Bild 1

🏷️ XnView MP Kategorien-Workflow

Damit konvertierte Bilder sofort im Kategorien-Filter von XnView MP sichtbar sind, schreibt das Skript das Schlagwort "Bilder" redundant in drei verschiedene Metadaten-Felder (IPTC Keywords, XMP-dc:Subject und XMP-lr:HierarchicalSubject).

Wichtig: Katalog-Aktualisierung Da XnView MP eine interne Datenbank nutzt, müssen die vom Skript geschriebenen Daten nach dem Export einmalig "angestossen" werden:

  1. Navigiere in XnView MP zum Ordner fotopool-neu.
  2. Markiere alle Dateien (Strg + A).
  3. Wähle im Menü: Metadaten > Katalog aus Dateien aktualisieren.

Dadurch werden die Häkchen in der Kategorie-Spalte bei "Bilder" sofort auf "Zugeordnet" gesetzt.

⚙️ Technische Highlights

Globaler Zähler & Chronologie

Im Gegensatz zu Original-iPhone-Ordnern verwendet dieses Skript einen lückenlosen, globalen Zähler über den gesamten Bestand. Das Dateiformat ist YYYYMMDD-TYP-00001.ext. Dadurch bleibt die Sortierung auch bei gleicher Aufnahmesekunde stabil.

Der 1971-Anker (Sorgenkinder)

Dateien ohne EXIF-Daten erhalten das Anker-Datum 09.09.1971.

  • Metadaten-Fix: Das Datum wird via ExifTool fest in die Datei geschrieben (EXIF bei Bildern, QuickTime-Tags bei Videos).

  • Unix-Sicherheit: 1971 liegt sicher nach dem Unix-Nullpunkt (1970), was Anzeigefehler in älterer Software verhindert.

  • System-Sync: Das Skript setzt das Änderungsdatum des Dateisystems (mtime) auf 1971, sodass Dateimanager und XnView MP die Dateien sofort korrekt oben einsortieren.

XnView MP Kompatibilität

Das Skript ist darauf optimiert, Metadaten so zu schreiben, dass XnView MP sie in den Reitern "EXIF" (Bilder) und "ExifTool" (Videos) direkt auslesen kann.


📝 Fehler-Management & Logging

Bei grossen Datenbeständen (z. B. 20'000+ Dateien) können vereinzelt Fehler auftreten (beschädigte Dateien, extrem lange Pfade im Quellordner). Damit der Fortschrittsbalken in der Konsole stabil bleibt, werden Fehler nicht direkt ausgegeben, sondern diskret protokolliert.

  • Log-Datei: Alle Probleme werden mit Zeitstempel in der Datei error_log.txt im Ordner fotopool-bin gespeichert.
  • Statistik: Am Ende jedes Durchlaufs zeigt das Skript eine Zusammenfassung der erfolgreichen und fehlgeschlagenen Operationen an.

Häufige Fehlermeldungen:

  • broken data stream: Die Quelldatei ist physisch beschädigt. Das Skript überspringt die Konvertierung und versucht eine einfache Kopie des Originals.
  • Path too long: Der Pfad im Quellverzeichnis (fotopool-alt) überschreitet die Systemgrenzen. Das Skript loggt dies und schlägt fehl – oft hilft es, den Quellordner umzubenennen oder flacher zu strukturieren.

📄 Das Skript (iphone-photo-manager.py)

Python

import os
import json
import subprocess
import shutil
import argparse
import sys
from datetime import datetime, timedelta

# --- HEIC/PILLOW KOMPATIBILITÄTS-CHECK ---
PILLOW_INSTALLED = False
HEIF_SUPPORT = False

try:
    from PIL import Image
    PILLOW_INSTALLED = True
    try:
        import pillow_heif
        pillow_heif.register_heif_opener()
        HEIF_SUPPORT = True
    except ImportError:
        HEIF_SUPPORT = False
except ImportError:
    PILLOW_INSTALLED = False

# --- RELATIVE PFADERMITTLUNG ---
BIN_DIR = os.path.dirname(os.path.abspath(__file__))
BASE_DIR = os.path.dirname(BIN_DIR)

SOURCE_DIR = os.path.join(BASE_DIR, "fotopool-alt")
TARGET_DIR_OK = os.path.join(BASE_DIR, "fotopool-neu")
TARGET_DIR_NO_DATE = os.path.join(BASE_DIR, "fotopool-ohne-datum")
DB_FILE = os.path.join(BIN_DIR, "photo_db.json")
LOG_FILE = os.path.join(BIN_DIR, "error_log.txt")

IMAGE_EXT = {'.jpg', '.jpeg', '.png', '.heic', '.heif', '.tiff', '.tif', '.dng', '.gif', '.bmp'}
VIDEO_EXT = {'.mov', '.mp4', '.m4v', '.qt', '.3gp'}

def log_error(message):
    """Schreibt Fehler diskret in die Log-Datei statt in die Konsole."""
    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    with open(LOG_FILE, "a") as f:
        f.write(f"[{timestamp}] {message}\n")

def print_progress(iteration, total, prefix=''):
    """Erzeugt einen stabilen Fortschrittsbalken."""
    percent = ("{0:.1f}").format(100 * (iteration / float(total)))
    filled_length = int(50 * iteration // total)
    bar = '█' * filled_length + '░' * (50 - filled_length)
    sys.stdout.write(f'\r{prefix} |{bar}| {percent}% ({iteration}/{total})')
    sys.stdout.flush()
    if iteration == total:
        sys.stdout.write('\n')

def get_exif_date(file_path):
    try:
        cmd = ["exiftool", "-s3", "-d", "%Y:%m:%d %H:%M:%S", "-CreateDate", "-DateTimeOriginal", "-CreationDate", file_path]
        result = subprocess.run(cmd, capture_output=True, text=True)
        dates = [d for d in result.stdout.strip().split('\n') if d]
        if dates:
            return datetime.strptime(dates[0], "%Y:%m:%d %H:%M:%S")
    except Exception as e:
        log_error(f"Metadaten-Fehler bei {file_path}: {e}")
    return None

def copy_metadata(source, target):
    cmd = ["exiftool", "-overwrite_original", "-TagsFromFile", source, "-all:all", "-unsafe", target]
    subprocess.run(cmd, capture_output=True)

def add_xnview_category(file_path, category="Bilder"):
    cmd = ["exiftool", "-overwrite_original", f"-IPTC:Keywords={category}", f"-XMP-dc:Subject={category}", f"-XMP-lr:HierarchicalSubject={category}", file_path]
    subprocess.run(cmd, capture_output=True)

def write_exif_date(file_path, date_obj):
    date_str = date_obj.strftime("%Y:%m:%d %H:%M:%S")
    ext = os.path.splitext(file_path)[1].lower()
    if ext in VIDEO_EXT:
        cmd = ["exiftool", "-overwrite_original", f"-QuickTime:CreateDate={date_str}", f"-QuickTime:ModifyDate={date_str}", f"-Keys:CreationDate={date_str}", file_path]
    else:
        cmd = ["exiftool", "-overwrite_original", f"-AllDates={date_str}", f"-CreationDate={date_str}", file_path]
    subprocess.run(cmd, capture_output=True)
    ts = date_obj.timestamp()
    os.utime(file_path, (ts, ts))

def scan():
    if os.path.exists(LOG_FILE): os.remove(LOG_FILE) # Log bei neuem Scan leeren
    print(f"Suche Dateien in {SOURCE_DIR}...")
    all_files = []
    if not os.path.exists(SOURCE_DIR): return

    for root, _, files in os.walk(SOURCE_DIR):
        for file in files:
            if os.path.splitext(file)[1].lower() in (IMAGE_EXT | VIDEO_EXT):
                all_files.append(os.path.join(root, file))
    
    total = len(all_files)
    if total == 0: return

    print(f"Analyse startet...")
    db = []
    for i, path in enumerate(all_files, start=1):
        date = get_exif_date(path)
        ext = os.path.splitext(path)[1].lower()
        file_type = "PIC" if ext in IMAGE_EXT else "VID"
        db.append({"orig_path": path, "full_timestamp": date.isoformat() if date else None, "day_string": date.strftime("%Y%m%d") if date else None, "type": file_type, "extension": ext})
        print_progress(i, total, prefix='Analyse')
    with open(DB_FILE, "w") as f: json.dump(db, f, indent=4)

def export(custom_name=None, jpg_quality=None):
    if not os.path.exists(DB_FILE): return
    with open(DB_FILE, "r") as f: db = json.load(f)
    
    final_list = [e for e in db if not e["full_timestamp"]] + sorted([e for e in db if e["full_timestamp"]], key=lambda x: x["full_timestamp"])
    total = len(final_list)
    error_count = 0
    
    os.makedirs(TARGET_DIR_OK, exist_ok=True)
    os.makedirs(TARGET_DIR_NO_DATE, exist_ok=True)
    anchor_start = datetime(1971, 9, 9, 0, 0, 0)
    print(f"Export startet...")
    
    for i, entry in enumerate(final_list, start=1):
        display_type = custom_name if custom_name else entry["type"]
        is_image = entry["extension"] in IMAGE_EXT
        target_ext = ".jpg" if (jpg_quality and is_image) else entry["extension"]
        
        if not entry["full_timestamp"]:
            date_obj = anchor_start + timedelta(seconds=i)
            new_name = f"19710909-{display_type}-{i:05d}{target_ext}"
            dest_path = os.path.join(TARGET_DIR_NO_DATE, new_name)
        else:
            date_obj = datetime.fromisoformat(entry["full_timestamp"])
            new_name = f"{entry['day_string']}-{display_type}-{i:05d}{target_ext}"
            dest_path = os.path.join(TARGET_DIR_OK, new_name)
        
        try:
            if jpg_quality and is_image:
                img = Image.open(entry['orig_path'])
                img.convert("RGB").save(dest_path, "JPEG", quality=jpg_quality)
                copy_metadata(entry['orig_path'], dest_path)
                add_xnview_category(dest_path, "Bilder")
            else:
                shutil.copy2(entry['orig_path'], dest_path)
            write_exif_date(dest_path, date_obj)
        except Exception as e:
            error_count += 1
            log_error(f"Verarbeitungs-Fehler bei {entry['orig_path']}: {e}")

        print_progress(i, total, prefix='Export ')

    print(f"\nVorgang abgeschlossen. Erfolgreich: {total - error_count} | Fehler (siehe Log): {error_count}")

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--scan", action="store_true")
    parser.add_argument("--export", action="store_true")
    parser.add_argument("--nameit", type=str)
    parser.add_argument("--extjpg5", action="store_true")
    parser.add_argument("--extjpg7", action="store_true")
    parser.add_argument("--extjpg10", action="store_true")
    
    args = parser.parse_args()
    quality = {args.extjpg5: 50, args.extjpg7: 75, args.extjpg10: 95}.get(True)

    if args.scan: scan()
    elif args.export:
        if quality and (not PILLOW_INSTALLED or not HEIF_SUPPORT):
            print("FEHLER: Pillow oder HEIF-Support fehlt.")
            sys.exit(1)
        export(custom_name=args.nameit, jpg_quality=quality)