From 3fafb621b0d9a30b66ce4e563d27be722ff342c9 Mon Sep 17 00:00:00 2001 From: "matthias.lotz" Date: Sun, 9 Nov 2025 13:30:58 +0100 Subject: [PATCH] docs: Add FEATURE_PLAN for EXIF metadata extraction Plan for implementing automatic EXIF data extraction from uploaded images: - Extract capture date, camera model, and GPS coordinates - Use earliest capture date for chronological group sorting - Add new database fields: capture_date, exif_date_taken, exif_camera_model - Implement ExifService with exifr library - Create migration script for existing images - Update slideshow sorting logic with EXIF-based chronology - Fallback to year/upload date when EXIF unavailable Estimated effort: 5-7 hours (3 phases) Dependencies: exifr npm package --- docs/FEATURE_PLAN-exif-extraction.md | 480 +++++++++++++++++++++++++++ 1 file changed, 480 insertions(+) create mode 100644 docs/FEATURE_PLAN-exif-extraction.md diff --git a/docs/FEATURE_PLAN-exif-extraction.md b/docs/FEATURE_PLAN-exif-extraction.md new file mode 100644 index 0000000..09a4f24 --- /dev/null +++ b/docs/FEATURE_PLAN-exif-extraction.md @@ -0,0 +1,480 @@ +# 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