Project-Image-Uploader/FeatureRequests/FEATURE_PLAN-exif-extraction.md

15 KiB

Feature Plan: EXIF-Daten Extraktion & Capture-Date Sortierung

Status: In Planung
Branch: feature/ExifExtraction
Erstellt: 09. November 2025

Ziel

Automatische Extraktion von EXIF-Metadaten (insbesondere Aufnahmedatum) aus hochgeladenen Bildern und Nutzung dieser Informationen für eine präzisere chronologische Sortierung der Slideshow-Gruppen.

Motivation

Aktuelles Verhalten

  • Slideshow-Gruppen werden nach Jahr (Benutzereingabe) und Upload-Datum sortiert
  • Aufnahmedatum der Bilder wird nicht berücksichtigt
  • Bei Bildern aus verschiedenen Jahren in einer Gruppe: Unklare Sortierung

Gewünschtes Verhalten

  • Automatische Extraktion des Aufnahmedatums (EXIF DateTimeOriginal) beim Upload
  • Nutzung des ältesten Aufnahmedatums einer Gruppe als primäres Sortier-Kriterium
  • Fallback auf Jahr/Upload-Datum wenn EXIF-Daten fehlen

Use Cases

Use Case 1: Alte Fotos digitalisieren

Szenario: Benutzer scannt alte Fotos von 1995 und lädt sie 2025 hoch

  • Problem: Gruppe würde aktuell in 2025 einsortiert
  • Lösung: EXIF-Datum (falls eingescannt mit Datum) oder Jahr-Eingabe nutzen

Use Case 2: Gemischte Jahre in einer Gruppe

Szenario: Benutzer lädt Bilder von einem Urlaub hoch, der über Jahreswechsel ging

  • 5 Bilder vom 28.12.2023
  • 8 Bilder vom 02.01.2024
  • Lösung: Gruppe wird nach ältestem Aufnahmedatum sortiert (28.12.2023)

Use Case 3: Bilder ohne EXIF

Szenario: Screenshots, bearbeitete Bilder, oder alte digitalisierte Fotos ohne Metadaten

  • Lösung: Fallback auf Jahr-Eingabe (wie bisher)

Technische Lösung

A. EXIF-Extraktion (Backend)

1. Dependency

Library: exif-parser oder exifr (npm)

npm install exifr --save

Warum exifr?

  • Modern, aktiv maintained
  • Unterstützt viele Formate (JPEG, TIFF, HEIC, etc.)
  • Promise-based API
  • Klein und performant
  • Keine Native Dependencies (Pure JavaScript)

2. Datenbank-Schema Migration

Neue Felder in images Tabelle:

ALTER TABLE images ADD COLUMN exif_date_taken DATETIME DEFAULT NULL;
ALTER TABLE images ADD COLUMN exif_camera_model TEXT DEFAULT NULL;
ALTER TABLE images ADD COLUMN exif_location_lat REAL DEFAULT NULL;
ALTER TABLE images ADD COLUMN exif_location_lon REAL DEFAULT NULL;

Neues berechnetes Feld in groups Tabelle:

ALTER TABLE groups ADD COLUMN capture_date DATETIME DEFAULT NULL;
-- Wird beim Upload berechnet: MIN(exif_date_taken) aller Bilder der Gruppe

Index für Performance:

CREATE INDEX IF NOT EXISTS idx_groups_capture_date ON groups(capture_date);
CREATE INDEX IF NOT EXISTS idx_images_exif_date_taken ON images(exif_date_taken);

3. EXIF Service (Backend)

Datei: backend/src/services/ExifService.js

const exifr = require('exifr');
const fs = require('fs').promises;

class ExifService {
    /**
     * Extrahiere EXIF-Daten aus einem Bild
     * @param {string} filePath - Absoluter Pfad zum Bild
     * @returns {Promise<Object>} EXIF-Daten oder null
     */
    async extractExifData(filePath) {
        try {
            const exifData = await exifr.parse(filePath, {
                pick: [
                    'DateTimeOriginal',    // Aufnahmedatum
                    'CreateDate',          // Fallback
                    'Make',                // Kamera-Hersteller
                    'Model',               // Kamera-Modell
                    'latitude',            // GPS-Koordinaten
                    'longitude'
                ]
            });

            if (!exifData) return null;

            return {
                dateTaken: exifData.DateTimeOriginal || exifData.CreateDate || null,
                cameraModel: exifData.Model ? `${exifData.Make || ''} ${exifData.Model}`.trim() : null,
                location: (exifData.latitude && exifData.longitude) 
                    ? { lat: exifData.latitude, lon: exifData.longitude }
                    : null
            };
        } catch (error) {
            console.warn(`[ExifService] Failed to extract EXIF from ${filePath}:`, error.message);
            return null;
        }
    }

    /**
     * Finde das älteste Aufnahmedatum in einer Gruppe
     * @param {Array} images - Array von Bild-Objekten mit exif_date_taken
     * @returns {Date|null} Ältestes Datum oder null
     */
    getEarliestCaptureDate(images) {
        const dates = images
            .map(img => img.exif_date_taken)
            .filter(date => date !== null)
            .map(date => new Date(date));

        if (dates.length === 0) return null;

        return new Date(Math.min(...dates));
    }
}

module.exports = new ExifService();

4. Upload-Route Integration

Datei: backend/src/routes/upload.js

const ExifService = require('../services/ExifService');

// Nach dem Speichern des Bildes:
router.post('/upload', async (req, res) => {
    // ... existing upload logic ...
    
    // EXIF-Extraktion für jedes Bild
    for (const file of uploadedFiles) {
        const filePath = path.join(UPLOAD_DIR, file.filename);
        const exifData = await ExifService.extractExifData(filePath);
        
        // In Datenbank speichern
        await db.run(`
            UPDATE images 
            SET exif_date_taken = ?,
                exif_camera_model = ?,
                exif_location_lat = ?,
                exif_location_lon = ?
            WHERE file_name = ?
        `, [
            exifData?.dateTaken || null,
            exifData?.cameraModel || null,
            exifData?.location?.lat || null,
            exifData?.location?.lon || null,
            file.filename
        ]);
    }
    
    // Berechne capture_date für Gruppe
    const images = await db.all('SELECT exif_date_taken FROM images WHERE group_id = ?', [groupId]);
    const captureDate = ExifService.getEarliestCaptureDate(images);
    
    await db.run(`
        UPDATE groups 
        SET capture_date = ?
        WHERE group_id = ?
    `, [captureDate?.toISOString() || null, groupId]);
    
    // ... rest of upload logic ...
});

B. Frontend-Änderungen

1. Slideshow-Sortierung

Datei: frontend/src/Components/Pages/SlideshowPage.js

Neue Sortier-Logik:

const sortedGroups = [...groupsData.groups].sort((a, b) => {
    // 1. Priorität: capture_date (EXIF-basiert)
    if (a.captureDate && b.captureDate) {
        return new Date(a.captureDate) - new Date(b.captureDate);
    }
    
    // 2. Priorität: Wenn nur eine Gruppe EXIF hat, diese zuerst
    if (a.captureDate && !b.captureDate) return -1;
    if (!a.captureDate && b.captureDate) return 1;
    
    // 3. Fallback: Jahr (Benutzereingabe)
    if (a.year !== b.year) {
        return a.year - b.year;
    }
    
    // 4. Fallback: Upload-Datum
    return new Date(a.uploadDate) - new Date(b.uploadDate);
});

2. Metadaten-Anzeige (Optional)

Datei: frontend/src/Components/Pages/SlideshowPage.js

Erweiterte Info-Box:

<Typography sx={metaTextSx}>
  {currentGroup.captureDate && (
    <>Aufnahme: {new Date(currentGroup.captureDate).toLocaleDateString('de-DE')}  </>
  )}
  Bild {currentImageIndex + 1} von {currentGroup.images.length}  
  Slideshow {currentGroupIndex + 1} von {allGroups.length}
</Typography>

C. Batch-Migration (Bestehende Bilder)

Datei: backend/src/scripts/migrate-exif.js

/**
 * Einmaliges Skript zum Extrahieren von EXIF-Daten aus bestehenden Bildern
 */
const ExifService = require('../services/ExifService');
const db = require('../database/DatabaseManager');
const path = require('path');

async function migrateExistingImages() {
    console.log('[EXIF Migration] Starting...');
    
    const images = await db.all('SELECT id, file_name, group_id FROM images WHERE exif_date_taken IS NULL');
    console.log(`[EXIF Migration] Found ${images.length} images without EXIF data`);
    
    let successCount = 0;
    let failCount = 0;
    
    for (const image of images) {
        const filePath = path.join(__dirname, '../data/images', image.file_name);
        const exifData = await ExifService.extractExifData(filePath);
        
        if (exifData && exifData.dateTaken) {
            await db.run(`
                UPDATE images 
                SET exif_date_taken = ?,
                    exif_camera_model = ?,
                    exif_location_lat = ?,
                    exif_location_lon = ?
                WHERE id = ?
            `, [
                exifData.dateTaken,
                exifData.cameraModel,
                exifData.location?.lat,
                exifData.location?.lon,
                image.id
            ]);
            successCount++;
        } else {
            failCount++;
        }
    }
    
    // Update capture_date für alle Gruppen
    const groups = await db.all('SELECT group_id FROM groups WHERE capture_date IS NULL');
    for (const group of groups) {
        const groupImages = await db.all('SELECT exif_date_taken FROM images WHERE group_id = ?', [group.group_id]);
        const captureDate = ExifService.getEarliestCaptureDate(groupImages);
        
        if (captureDate) {
            await db.run('UPDATE groups SET capture_date = ? WHERE group_id = ?', [
                captureDate.toISOString(),
                group.group_id
            ]);
        }
    }
    
    console.log(`[EXIF Migration] Complete! Success: ${successCount}, Failed: ${failCount}`);
}

// Run migration
migrateExistingImages().catch(console.error);

Implementierungs-Plan

Phase 1: Backend - EXIF Extraktion (3-4 Stunden)

  1. Dependencies installieren (10 min)

    • npm install exifr im Backend
    • Package-Lock aktualisieren
  2. Datenbank-Migration (30 min)

    • Migration-Script erstellen (migration-005-exif.sql)
    • Neue Felder in images Tabelle
    • Neues Feld capture_date in groups Tabelle
    • Indizes erstellen
    • Migration testen
  3. ExifService implementieren (60 min)

    • services/ExifService.js erstellen
    • extractExifData() Methode
    • getEarliestCaptureDate() Methode
    • Error-Handling
    • Unit-Tests (optional)
  4. Upload-Route erweitern (60 min)

    • EXIF-Extraktion in Upload-Flow integrieren
    • Datenbank-Updates
    • capture_date Berechnung
    • Testing mit verschiedenen Bildtypen
  5. Migration-Script (30 min)

    • scripts/migrate-exif.js erstellen
    • Batch-Processing für bestehende Bilder
    • Logging und Progress-Anzeige
    • Test mit Development-Daten

Phase 2: Frontend - Sortierung & Anzeige (1-2 Stunden)

  1. Slideshow-Sortierung (30 min)

    • SlideshowPage.js anpassen
    • Neue Sortier-Logik mit captureDate
    • Fallback-Logik testen
  2. Metadaten-Anzeige (30 min, Optional)

    • Aufnahmedatum in Info-Box anzeigen
    • Kamera-Modell anzeigen (optional)
    • Responsive Design
  3. Groups-Overview (30 min, Optional)

    • EXIF-Daten in Gruppen-Übersicht anzeigen
    • Filter nach Kamera-Modell (optional)

Phase 3: Testing & Dokumentation (1 Stunde)

  1. Testing (30 min)

    • Upload mit EXIF-Daten
    • Upload ohne EXIF-Daten (Screenshots)
    • Sortierung mit gemischten Gruppen
    • Migration-Script auf bestehenden Daten
    • Performance-Test (viele Bilder)
  2. Dokumentation (30 min)

    • README.md aktualisieren
    • CHANGELOG.md Entry
    • API-Dokumentation (falls vorhanden)
    • Migration-Anleitung

Technische Details

EXIF-Datenformate

EXIF DateTimeOriginal:

  • Format: "YYYY:MM:DD HH:MM:SS" (z.B. "2023:12:28 15:30:45")
  • Konvertierung zu ISO-8601: new Date("2023-12-28T15:30:45").toISOString()

GPS-Koordinaten:

  • latitude: -90 bis +90 (Süd zu Nord)
  • longitude: -180 bis +180 (West zu Ost)

Performance-Überlegungen

EXIF-Extraktion ist I/O-intensiv:

  • Pro Bild: ~10-50ms (je nach Größe)
  • 10 Bilder: ~100-500ms zusätzlich beim Upload
  • Mitigation: Async/Parallel processing mit Promise.all()

Speicherverbrauch:

  • EXIF-Daten: ~100-500 bytes pro Bild in DB
  • Vernachlässigbar bei 1000+ Bildern

Erwartete Verbesserungen

Sortierung

  • Präzise chronologische Sortierung nach tatsächlichem Aufnahmedatum
  • Automatisch, keine manuelle Eingabe nötig
  • Funktioniert auch bei Bildern aus verschiedenen Jahren in einer Gruppe

User Experience

  • Mehr Kontext durch Anzeige von Kamera-Modell
  • Potenziell: Geo-Tagging für Location-basierte Features (Zukunft)
  • Bessere Archivierung alter Fotos

Daten-Qualität

  • Strukturierte Metadaten in Datenbank
  • Basis für zukünftige Features (z.B. Kamera-Filter, Geo-Map)

Risiken & Mitigationen

Risiko 1: Bilder ohne EXIF

Problem: Screenshots, bearbeitete Bilder, oder alte Scans haben keine EXIF-Daten
Mitigation: Fallback auf Jahr-Eingabe und Upload-Datum (wie bisher)

Risiko 2: Falsche EXIF-Daten

Problem: Kamera-Uhrzeit falsch eingestellt
Mitigation: User-Eingabe (Jahr) hat Vorrang, EXIF nur als Ergänzung

Risiko 3: Performance beim Upload

Problem: EXIF-Extraktion verlangsamt Upload
Mitigation:

  • Parallel-Processing mit Promise.all()
  • Timeout (max 100ms pro Bild)
  • Falls Timeout: Upload trotzdem erfolgreich, EXIF später nachholen

Risiko 4: HEIC/HEIF Support

Problem: Apple-Formate nicht von allen Libraries unterstützt
Mitigation: exifr unterstützt HEIC nativ

Alternativen

Alternative 1: Nur Jahr-Feld nutzen (Status Quo)

Nachteil: Ungenau bei gemischten Jahren, manuelle Eingabe erforderlich

Alternative 2: Server-Side Image Processing (Sharp + EXIF)

const sharp = require('sharp');
const metadata = await sharp(filePath).metadata();

Nachteil: sharp hat native dependencies (schwierigeres Deployment)

Alternative 3: Frontend EXIF-Extraktion

Nachteil: Mehr Client-Traffic, nicht bei allen Browsern zuverlässig

Erfolgs-Kriterien

Must-Have:

  1. EXIF-Daten werden beim Upload automatisch extrahiert
  2. capture_date wird korrekt berechnet (ältestes Bild der Gruppe)
  3. Slideshow sortiert nach capture_date (mit Fallback)
  4. Migration-Script funktioniert für bestehende Bilder
  5. Upload-Performance bleibt akzeptabel (<500ms zusätzlich für 10 Bilder)

Nice-to-Have:

  1. Anzeige von Aufnahmedatum in Slideshow
  2. Anzeige von Kamera-Modell
  3. GPS-Koordinaten gespeichert (für zukünftige Features)

Offene Fragen

  • Soll Aufnahmedatum editierbar sein (falls EXIF fehlt oder falsch)?
  • Soll Kamera-Modell in der Gruppen-Übersicht angezeigt werden?
  • Sollen GPS-Koordinaten für Geo-Tagging genutzt werden (Map-View)?
  • Soll EXIF-Extraktion synchron (beim Upload) oder asynchron (Background-Job) erfolgen?

Rollout-Plan

  1. Development (feature/ExifExtraction Branch)

    • Implementierung & Unit-Tests
    • Migration-Script testen
    • Code Review
  2. Staging/Testing

    • Migration auf Dev-Environment
    • Test-Uploads mit verschiedenen Bildtypen
    • Performance-Messungen
  3. Production

    1. Datenbank-Backup
    2. Migration-Script ausführen
    3. Deployment via Docker Compose
    4. Monitoring für 24h

Erstellt von: GitHub Copilot
Review durch: @lotzm