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
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)
-
Dependencies installieren (10 min)
npm install exifrim Backend- Package-Lock aktualisieren
-
Datenbank-Migration (30 min)
- Migration-Script erstellen (
migration-005-exif.sql) - Neue Felder in
imagesTabelle - Neues Feld
capture_dateingroupsTabelle - Indizes erstellen
- Migration testen
- Migration-Script erstellen (
-
ExifService implementieren (60 min)
services/ExifService.jserstellenextractExifData()MethodegetEarliestCaptureDate()Methode- Error-Handling
- Unit-Tests (optional)
-
Upload-Route erweitern (60 min)
- EXIF-Extraktion in Upload-Flow integrieren
- Datenbank-Updates
capture_dateBerechnung- Testing mit verschiedenen Bildtypen
-
Migration-Script (30 min)
scripts/migrate-exif.jserstellen- Batch-Processing für bestehende Bilder
- Logging und Progress-Anzeige
- Test mit Development-Daten
Phase 2: Frontend - Sortierung & Anzeige (1-2 Stunden)
-
Slideshow-Sortierung (30 min)
SlideshowPage.jsanpassen- Neue Sortier-Logik mit
captureDate - Fallback-Logik testen
-
Metadaten-Anzeige (30 min, Optional)
- Aufnahmedatum in Info-Box anzeigen
- Kamera-Modell anzeigen (optional)
- Responsive Design
-
Groups-Overview (30 min, Optional)
- EXIF-Daten in Gruppen-Übersicht anzeigen
- Filter nach Kamera-Modell (optional)
Phase 3: Testing & Dokumentation (1 Stunde)
-
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)
-
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:
- EXIF-Daten werden beim Upload automatisch extrahiert
capture_datewird korrekt berechnet (ältestes Bild der Gruppe)- Slideshow sortiert nach
capture_date(mit Fallback) - Migration-Script funktioniert für bestehende Bilder
- Upload-Performance bleibt akzeptabel (<500ms zusätzlich für 10 Bilder)
✅ Nice-to-Have:
- Anzeige von Aufnahmedatum in Slideshow
- Anzeige von Kamera-Modell
- 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
-
Development (feature/ExifExtraction Branch)
- Implementierung & Unit-Tests
- Migration-Script testen
- Code Review
-
Staging/Testing
- Migration auf Dev-Environment
- Test-Uploads mit verschiedenen Bildtypen
- Performance-Messungen
-
Production
- Datenbank-Backup
- Migration-Script ausführen
- Deployment via Docker Compose
- Monitoring für 24h
Erstellt von: GitHub Copilot
Review durch: @lotzm