# 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) ```bash 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**: ```sql 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**: ```sql 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**: ```sql 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` ```javascript 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} 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` ```javascript 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**: ```javascript 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**: ```jsx {currentGroup.captureDate && ( <>Aufnahme: {new Date(currentGroup.captureDate).toLocaleDateString('de-DE')} • )} Bild {currentImageIndex + 1} von {currentGroup.images.length} • Slideshow {currentGroupIndex + 1} von {allGroups.length} ``` ### C. Batch-Migration (Bestehende Bilder) **Datei**: `backend/src/scripts/migrate-exif.js` ```javascript /** * 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) ```javascript 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