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

481 lines
15 KiB
Markdown

# 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<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`
```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
<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`
```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