diff --git a/aurora-livecam/SettingsManager.php b/aurora-livecam/SettingsManager.php
index 56313c1..ad9b88b 100644
--- a/aurora-livecam/SettingsManager.php
+++ b/aurora-livecam/SettingsManager.php
@@ -282,7 +282,7 @@ class SettingsManager {
}
public function isWeeklyTimelapseEnabled() {
- return $this->get('zoom_timelapse.weekly_timelapse_enabled') !== true;
+ return $this->get('zoom_timelapse.weekly_timelapse_enabled') !== false;
}
// Auto-Screenshot Helper
diff --git a/aurora-livecam/WeatherManager.php b/aurora-livecam/WeatherManager.php
index 2a05ba7..66869ed 100644
--- a/aurora-livecam/WeatherManager.php
+++ b/aurora-livecam/WeatherManager.php
@@ -27,9 +27,24 @@ class WeatherManager {
return $cached;
}
- // Hole frische Daten von API (Open-Meteo)
$coords = $this->settingsManager->getWeatherCoords();
+ $apiKey = trim($this->settingsManager->getWeatherApiKey());
+ $weather = $apiKey !== ''
+ ? $this->fetchOpenWeather($coords, $apiKey)
+ : $this->fetchOpenMeteo($coords);
+
+ if (isset($weather['error'])) {
+ return $weather;
+ }
+
+ // Cache speichern
+ $this->saveCache($weather);
+
+ return $weather;
+ }
+
+ private function fetchOpenMeteo($coords) {
// Open-Meteo API URL - komplett kostenlos, kein API Key!
$url = "https://api.open-meteo.com/v1/forecast?" . http_build_query([
'latitude' => $coords['lat'],
@@ -38,17 +53,8 @@ class WeatherManager {
'timezone' => 'Europe/Zurich'
]);
- $ch = curl_init();
- curl_setopt($ch, CURLOPT_URL, $url);
- curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
- curl_setopt($ch, CURLOPT_TIMEOUT, 5);
- curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
-
- $response = curl_exec($ch);
- $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
- curl_close($ch);
-
- if ($httpCode !== 200 || !$response) {
+ $response = $this->fetchUrl($url);
+ if ($response === null) {
return ['error' => 'API Fehler'];
}
@@ -59,8 +65,7 @@ class WeatherManager {
$current = $data['current'];
- // Formatiere Daten
- $weather = [
+ return [
'temp' => round($current['temperature_2m'], 1),
'feels_like' => round($current['temperature_2m'], 1), // Open-Meteo hat keine "feels like"
'humidity' => $current['relative_humidity_2m'],
@@ -76,11 +81,67 @@ class WeatherManager {
'location' => $this->settingsManager->getWeatherLocation(),
'timestamp' => time()
];
+ }
- // Cache speichern
- $this->saveCache($weather);
+ private function fetchOpenWeather($coords, $apiKey) {
+ $units = $this->settingsManager->getWeatherUnits();
+ $url = "https://api.openweathermap.org/data/2.5/weather?" . http_build_query([
+ 'lat' => $coords['lat'],
+ 'lon' => $coords['lon'],
+ 'appid' => $apiKey,
+ 'units' => $units,
+ 'lang' => 'de'
+ ]);
- return $weather;
+ $response = $this->fetchUrl($url);
+ if ($response === null) {
+ return ['error' => 'API Fehler'];
+ }
+
+ $data = json_decode($response, true);
+ if (!$data || !isset($data['main'], $data['weather'][0], $data['wind'])) {
+ return ['error' => 'Ungültige API Antwort'];
+ }
+
+ $windSpeed = $data['wind']['speed'];
+ if ($units === 'metric') {
+ $windSpeed = $windSpeed * 3.6; // m/s -> km/h
+ }
+
+ return [
+ 'temp' => round($data['main']['temp'], 1),
+ 'feels_like' => round($data['main']['feels_like'], 1),
+ 'humidity' => $data['main']['humidity'],
+ 'pressure' => round($data['main']['pressure'], 0),
+ 'wind_speed' => round($windSpeed, 1),
+ 'wind_deg' => $data['wind']['deg'] ?? 0,
+ 'wind_direction' => $this->getWindDirection($data['wind']['deg'] ?? 0),
+ 'clouds' => $data['clouds']['all'] ?? 0,
+ 'description' => ucfirst($data['weather'][0]['description']),
+ 'icon' => $data['weather'][0]['icon'] ?? '01d',
+ 'rain_1h' => $data['rain']['1h'] ?? 0,
+ 'snow_1h' => $data['snow']['1h'] ?? 0,
+ 'location' => $data['name'] ?? $this->settingsManager->getWeatherLocation(),
+ 'timestamp' => time()
+ ];
+ }
+
+ private function fetchUrl($url) {
+ $ch = curl_init();
+ curl_setopt($ch, CURLOPT_URL, $url);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_TIMEOUT, 5);
+ curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
+
+ $response = curl_exec($ch);
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if ($httpCode !== 200 || !$response) {
+ return null;
+ }
+
+ return $response;
}
/**
diff --git a/aurora-livecam/api/auto-screenshot.php b/aurora-livecam/api/auto-screenshot.php
new file mode 100644
index 0000000..406a399
--- /dev/null
+++ b/aurora-livecam/api/auto-screenshot.php
@@ -0,0 +1,104 @@
+isAutoScreenshotEnabled()) {
+ echo json_encode(['success' => false, 'error' => 'Auto-Screenshot deaktiviert']);
+ exit;
+}
+
+// Optionale API-Key Validierung
+$configFile = dirname(__DIR__) . '/config.php';
+if (file_exists($configFile)) {
+ $config = require $configFile;
+ $apiKey = $config['auto_screenshot_key'] ?? '';
+
+ if (!empty($apiKey) && ($_GET['key'] ?? '') !== $apiKey) {
+ http_response_code(403);
+ echo json_encode(['success' => false, 'error' => 'Ungültiger API-Key']);
+ exit;
+ }
+}
+
+// Galerie-Verzeichnis erstellen
+$galleryDir = dirname(__DIR__) . '/gallery/auto/';
+if (!is_dir($galleryDir)) {
+ mkdir($galleryDir, 0755, true);
+}
+
+// Screenshot-Dateiname
+$filename = 'auto_' . date('Y-m-d_H-i-s') . '.jpg';
+$filepath = $galleryDir . $filename;
+
+// Video-Stream URL
+$streamUrl = 'test_video.m3u8';
+$logoPath = dirname(__DIR__) . '/logo.png';
+
+// FFmpeg-Befehl zum Erstellen des Screenshots
+$command = sprintf(
+ 'ffmpeg -i %s -vframes 1 -q:v 2 %s 2>&1',
+ escapeshellarg($streamUrl),
+ escapeshellarg($filepath)
+);
+
+exec($command, $output, $returnVar);
+
+if ($returnVar !== 0 || !file_exists($filepath)) {
+ echo json_encode([
+ 'success' => false,
+ 'error' => 'Screenshot fehlgeschlagen',
+ 'command' => $command,
+ 'output' => implode("\n", $output)
+ ]);
+ exit;
+}
+
+// Alte Screenshots aufräumen (max. Anzahl einhalten)
+$maxImages = $settingsManager->getAutoScreenshotMaxImages();
+$existingFiles = glob($galleryDir . 'auto_*.jpg');
+rsort($existingFiles); // Neueste zuerst
+
+if (count($existingFiles) > $maxImages) {
+ $filesToDelete = array_slice($existingFiles, $maxImages);
+ foreach ($filesToDelete as $file) {
+ @unlink($file);
+ }
+}
+
+// Metadaten speichern
+$metaFile = $galleryDir . 'metadata.json';
+$metadata = [];
+if (file_exists($metaFile)) {
+ $metadata = json_decode(file_get_contents($metaFile), true) ?? [];
+}
+
+$metadata[$filename] = [
+ 'created_at' => date('Y-m-d H:i:s'),
+ 'timestamp' => time(),
+ 'size' => filesize($filepath)
+];
+
+// Nur die letzten maxImages behalten
+$metadata = array_slice($metadata, -$maxImages, null, true);
+file_put_contents($metaFile, json_encode($metadata, JSON_PRETTY_PRINT));
+
+echo json_encode([
+ 'success' => true,
+ 'file' => $filename,
+ 'path' => '/gallery/auto/' . $filename,
+ 'total_images' => count(glob($galleryDir . 'auto_*.jpg'))
+]);
diff --git a/aurora-livecam/api/gallery.php b/aurora-livecam/api/gallery.php
new file mode 100644
index 0000000..4ecee5f
--- /dev/null
+++ b/aurora-livecam/api/gallery.php
@@ -0,0 +1,97 @@
+ true, 'images' => [], 'total' => 0]);
+ exit;
+}
+
+// Parameter
+$date = $_GET['date'] ?? null;
+$from = $_GET['from'] ?? null;
+$to = $_GET['to'] ?? null;
+$limit = min(100, (int)($_GET['limit'] ?? 50));
+$offset = max(0, (int)($_GET['offset'] ?? 0));
+
+// Alle Bilder holen
+$allFiles = glob($galleryDir . 'auto_*.jpg');
+rsort($allFiles); // Neueste zuerst
+
+$images = [];
+
+foreach ($allFiles as $file) {
+ $filename = basename($file);
+ // Extrahiere Datum aus Dateinamen: auto_2024-01-30_14-30-00.jpg
+ if (preg_match('/auto_(\d{4}-\d{2}-\d{2})_(\d{2}-\d{2}-\d{2})\.jpg/', $filename, $matches)) {
+ $fileDate = $matches[1];
+ $fileTime = str_replace('-', ':', $matches[2]);
+
+ // Datumsfilter
+ if ($date !== null && $fileDate !== $date) {
+ continue;
+ }
+
+ if ($from !== null && $fileDate < $from) {
+ continue;
+ }
+
+ if ($to !== null && $fileDate > $to) {
+ continue;
+ }
+
+ $images[] = [
+ 'filename' => $filename,
+ 'path' => '/gallery/auto/' . $filename,
+ 'date' => $fileDate,
+ 'time' => $fileTime,
+ 'datetime' => $fileDate . ' ' . $fileTime,
+ 'timestamp' => strtotime($fileDate . ' ' . $fileTime),
+ 'size' => filesize($file)
+ ];
+ }
+}
+
+$total = count($images);
+
+// Pagination
+$images = array_slice($images, $offset, $limit);
+
+// Verfügbare Daten (für Kalender/Filter)
+$availableDates = [];
+foreach (glob($galleryDir . 'auto_*.jpg') as $file) {
+ if (preg_match('/auto_(\d{4}-\d{2}-\d{2})/', basename($file), $m)) {
+ $availableDates[$m[1]] = ($availableDates[$m[1]] ?? 0) + 1;
+ }
+}
+krsort($availableDates);
+
+echo json_encode([
+ 'success' => true,
+ 'images' => $images,
+ 'total' => $total,
+ 'offset' => $offset,
+ 'limit' => $limit,
+ 'available_dates' => $availableDates,
+ 'filters' => [
+ 'date' => $date,
+ 'from' => $from,
+ 'to' => $to
+ ]
+]);
diff --git a/aurora-livecam/api/share.php b/aurora-livecam/api/share.php
new file mode 100644
index 0000000..8591642
--- /dev/null
+++ b/aurora-livecam/api/share.php
@@ -0,0 +1,315 @@
+isEmailSharingEnabled()) {
+ echo json_encode(['success' => false, 'error' => 'E-Mail-Sharing ist deaktiviert']);
+ exit;
+}
+
+// Config laden
+$configFile = dirname(__DIR__) . '/config.php';
+$config = file_exists($configFile) ? require $configFile : [];
+$mailConfig = $config['mail'] ?? [];
+
+if (empty($mailConfig['host']) || empty($mailConfig['username'])) {
+ echo json_encode(['success' => false, 'error' => 'E-Mail-Server nicht konfiguriert']);
+ exit;
+}
+
+// === GET: Share-Link generieren ===
+if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['generate'])) {
+ $path = $_GET['path'] ?? '';
+ $type = $_GET['type'] ?? 'video';
+
+ if (empty($path)) {
+ echo json_encode(['success' => false, 'error' => 'Kein Pfad angegeben']);
+ exit;
+ }
+
+ // Token generieren
+ $expiryHours = $settingsManager->getShareLinkExpiryHours();
+ $expiry = time() + ($expiryHours * 3600);
+ $token = hash_hmac('sha256', $path . $expiry, session_id() . 'share_secret');
+
+ // Share-Link speichern
+ $shareDir = dirname(__DIR__) . '/data/shares/';
+ if (!is_dir($shareDir)) {
+ mkdir($shareDir, 0755, true);
+ }
+
+ $shareId = bin2hex(random_bytes(16));
+ $shareData = [
+ 'id' => $shareId,
+ 'path' => $path,
+ 'type' => $type,
+ 'token' => $token,
+ 'expiry' => $expiry,
+ 'created_at' => date('Y-m-d H:i:s')
+ ];
+
+ file_put_contents($shareDir . $shareId . '.json', json_encode($shareData));
+
+ // URL generieren
+ $baseUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
+ . '://' . $_SERVER['HTTP_HOST'];
+ $shareUrl = $baseUrl . '/api/share.php?view=' . $shareId;
+
+ echo json_encode([
+ 'success' => true,
+ 'share_url' => $shareUrl,
+ 'share_id' => $shareId,
+ 'expires_at' => date('Y-m-d H:i:s', $expiry)
+ ]);
+ exit;
+}
+
+// === GET: Share-Link anzeigen ===
+if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['view'])) {
+ $shareId = preg_replace('/[^a-f0-9]/', '', $_GET['view']);
+ $shareFile = dirname(__DIR__) . '/data/shares/' . $shareId . '.json';
+
+ if (!file_exists($shareFile)) {
+ header('Content-Type: text/html; charset=utf-8');
+ echo '
Link ungültig❌ Link nicht gefunden
Dieser Share-Link existiert nicht oder wurde bereits gelöscht.
';
+ exit;
+ }
+
+ $shareData = json_decode(file_get_contents($shareFile), true);
+
+ // Ablauf prüfen
+ if (time() > $shareData['expiry']) {
+ @unlink($shareFile);
+ header('Content-Type: text/html; charset=utf-8');
+ echo 'Link abgelaufen⏰ Link abgelaufen
Dieser Share-Link ist abgelaufen. Bitte fordere einen neuen Link an.
';
+ exit;
+ }
+
+ // Datei existiert?
+ $filePath = dirname(__DIR__) . $shareData['path'];
+ if (!file_exists($filePath)) {
+ header('Content-Type: text/html; charset=utf-8');
+ echo 'Datei nicht gefunden📭 Datei nicht gefunden
Die geteilte Datei existiert nicht mehr.
';
+ exit;
+ }
+
+ // Redirect zur Datei oder HTML-Seite mit eingebettetem Player
+ $isVideo = in_array(pathinfo($filePath, PATHINFO_EXTENSION), ['mp4', 'webm', 'mov']);
+ $isImage = in_array(pathinfo($filePath, PATHINFO_EXTENSION), ['jpg', 'jpeg', 'png', 'gif', 'webp']);
+
+ $siteName = $config['app']['name'] ?? 'Aurora Livecam';
+ $baseUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
+ . '://' . $_SERVER['HTTP_HOST'];
+
+ header('Content-Type: text/html; charset=utf-8');
+ echo '
+
+
+
+
+ Geteilte ' . ($isVideo ? 'Video' : 'Bild') . ' - ' . htmlspecialchars($siteName) . '
+
+
+
+
+
📤 Geteilte' . ($isVideo ? 's Video' : 's Bild') . '
';
+
+ if ($isVideo) {
+ echo '
';
+ } else {
+ echo '
 . ')
';
+ }
+
+ echo '
+
⬇️ Herunterladen
+
+
+
+';
+ exit;
+}
+
+// === POST: E-Mail senden ===
+if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+ http_response_code(405);
+ echo json_encode(['success' => false, 'error' => 'Nur POST erlaubt']);
+ exit;
+}
+
+// JSON-Body parsen
+$input = json_decode(file_get_contents('php://input'), true);
+if (!$input) {
+ $input = $_POST;
+}
+
+$email = filter_var($input['email'] ?? '', FILTER_VALIDATE_EMAIL);
+$path = $input['path'] ?? '';
+$type = $input['type'] ?? 'video';
+$message = htmlspecialchars($input['message'] ?? '');
+$senderName = htmlspecialchars($input['sender_name'] ?? 'Ein Freund');
+
+if (!$email) {
+ echo json_encode(['success' => false, 'error' => 'Ungültige E-Mail-Adresse']);
+ exit;
+}
+
+if (empty($path)) {
+ echo json_encode(['success' => false, 'error' => 'Kein Pfad angegeben']);
+ exit;
+}
+
+// Share-Link generieren
+$expiryHours = $settingsManager->getShareLinkExpiryHours();
+$expiry = time() + ($expiryHours * 3600);
+
+$shareDir = dirname(__DIR__) . '/data/shares/';
+if (!is_dir($shareDir)) {
+ mkdir($shareDir, 0755, true);
+}
+
+$shareId = bin2hex(random_bytes(16));
+$shareData = [
+ 'id' => $shareId,
+ 'path' => $path,
+ 'type' => $type,
+ 'expiry' => $expiry,
+ 'created_at' => date('Y-m-d H:i:s'),
+ 'shared_to' => $email
+];
+
+file_put_contents($shareDir . $shareId . '.json', json_encode($shareData));
+
+$baseUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
+ . '://' . $_SERVER['HTTP_HOST'];
+$shareUrl = $baseUrl . '/api/share.php?view=' . $shareId;
+$siteName = $config['app']['name'] ?? 'Aurora Livecam';
+
+// E-Mail senden
+try {
+ $mail = new PHPMailer(true);
+
+ // SMTP Konfiguration
+ $mail->isSMTP();
+ $mail->Host = $mailConfig['host'];
+ $mail->SMTPAuth = true;
+ $mail->Username = $mailConfig['username'];
+ $mail->Password = $mailConfig['password'];
+ $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
+ $mail->Port = $mailConfig['port'] ?? 587;
+ $mail->CharSet = 'UTF-8';
+
+ // Absender/Empfänger
+ $mail->setFrom($mailConfig['from_address'], $mailConfig['from_name'] ?? $siteName);
+ $mail->addAddress($email);
+
+ // Inhalt
+ $mail->isHTML(true);
+ $mail->Subject = $senderName . ' hat ' . ($type === 'video' ? 'ein Video' : 'ein Bild') . ' mit dir geteilt';
+
+ $mail->Body = '
+
+
+
📤 ' . htmlspecialchars($siteName) . '
+
+
+
+ ' . htmlspecialchars($senderName) . ' hat ' . ($type === 'video' ? 'ein Video' : 'ein Bild') . ' mit dir geteilt!
+
+ ' . (!empty($message) ? '
"' . nl2br($message) . '"
' : '') . '
+
+ ▶️ Jetzt ansehen
+
+
+ Dieser Link ist ' . $expiryHours . ' Stunden gültig.
+
+
+
';
+
+ $mail->AltBody = $senderName . ' hat ' . ($type === 'video' ? 'ein Video' : 'ein Bild') . ' mit dir geteilt: ' . $shareUrl;
+
+ $mail->send();
+
+ echo json_encode([
+ 'success' => true,
+ 'message' => 'E-Mail wurde gesendet',
+ 'share_url' => $shareUrl
+ ]);
+
+} catch (Exception $e) {
+ error_log('Share email error: ' . $e->getMessage());
+ echo json_encode([
+ 'success' => false,
+ 'error' => 'E-Mail konnte nicht gesendet werden',
+ 'share_url' => $shareUrl // URL trotzdem zurückgeben
+ ]);
+}
diff --git a/aurora-livecam/api/video-search.php b/aurora-livecam/api/video-search.php
new file mode 100644
index 0000000..751cb81
--- /dev/null
+++ b/aurora-livecam/api/video-search.php
@@ -0,0 +1,192 @@
+ [],
+ 'ai_videos' => [],
+ 'gallery_images' => []
+];
+
+// AI-Kategorien
+$aiCategories = ['sunny', 'rainy', 'snowy', 'planes', 'birds', 'sunset', 'sunrise', 'rainbow'];
+
+// === TAGESVIDEOS SUCHEN ===
+if ($type === 'all' || $type === 'daily') {
+ $pattern = $videoDir . 'daily_video_*.mp4';
+ $dailyVideos = glob($pattern);
+
+ foreach ($dailyVideos as $video) {
+ $filename = basename($video);
+
+ // Extrahiere Datum aus Dateinamen: daily_video_YYYYMMDD_HHMMSS.mp4
+ if (preg_match('/daily_video_(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})\.mp4/', $filename, $matches)) {
+ $videoDate = $matches[1] . '-' . $matches[2] . '-' . $matches[3];
+ $videoTime = $matches[4] . ':' . $matches[5];
+ $videoDateTime = $videoDate . ' ' . $videoTime . ':' . $matches[6];
+
+ // Datumsfilter
+ if ($date !== null && $videoDate !== $date) {
+ continue;
+ }
+
+ if ($fromDate !== null && $videoDate < $fromDate) {
+ continue;
+ }
+
+ if ($toDate !== null && $videoDate > $toDate) {
+ continue;
+ }
+
+ // Uhrzeitfilter
+ if ($timeFrom !== null && $videoTime < $timeFrom) {
+ continue;
+ }
+
+ if ($timeTo !== null && $videoTime > $timeTo) {
+ continue;
+ }
+
+ // Spezifische Uhrzeit (mit 30 Min Toleranz)
+ if ($time !== null) {
+ $searchMinutes = intval(substr($time, 0, 2)) * 60 + intval(substr($time, 3, 2));
+ $videoMinutes = intval($matches[4]) * 60 + intval($matches[5]);
+
+ if (abs($searchMinutes - $videoMinutes) > 30) {
+ continue;
+ }
+ }
+
+ $results['daily_videos'][] = [
+ 'type' => 'daily',
+ 'filename' => $filename,
+ 'path' => '/videos/' . $filename,
+ 'date' => $videoDate,
+ 'time' => $videoTime,
+ 'datetime' => $videoDateTime,
+ 'timestamp' => strtotime($videoDateTime),
+ 'size' => filesize($video),
+ 'size_mb' => round(filesize($video) / (1024 * 1024), 2)
+ ];
+ }
+ }
+}
+
+// === AI-VIDEOS SUCHEN ===
+if ($type === 'all' || $type === 'ai') {
+ $searchCategories = $aiCategory ? [$aiCategory] : $aiCategories;
+
+ foreach ($searchCategories as $category) {
+ $categoryDir = $aiDir . $category . '/';
+ if (!is_dir($categoryDir)) continue;
+
+ $pattern = $categoryDir . $category . '_*.mp4';
+ $aiVideos = glob($pattern);
+
+ foreach ($aiVideos as $video) {
+ $filename = basename($video);
+
+ // Extrahiere Datum aus Dateinamen: category_YYYYMMDD_HHMMSS.mp4
+ if (preg_match('/' . $category . '_(\d{4})(\d{2})(\d{2})_?(\d{2})?(\d{2})?(\d{2})?\.mp4/', $filename, $matches)) {
+ $videoDate = $matches[1] . '-' . $matches[2] . '-' . $matches[3];
+ $videoTime = isset($matches[4]) ? ($matches[4] . ':' . ($matches[5] ?? '00')) : '00:00';
+ $videoDateTime = $videoDate . ' ' . $videoTime;
+
+ // Datumsfilter
+ if ($date !== null && $videoDate !== $date) {
+ continue;
+ }
+
+ if ($fromDate !== null && $videoDate < $fromDate) {
+ continue;
+ }
+
+ if ($toDate !== null && $videoDate > $toDate) {
+ continue;
+ }
+
+ // Uhrzeitfilter
+ if ($timeFrom !== null && $videoTime < $timeFrom) {
+ continue;
+ }
+
+ if ($timeTo !== null && $videoTime > $timeTo) {
+ continue;
+ }
+
+ $results['ai_videos'][] = [
+ 'type' => 'ai',
+ 'category' => $category,
+ 'filename' => $filename,
+ 'path' => '/ai/' . $category . '/' . $filename,
+ 'date' => $videoDate,
+ 'time' => $videoTime,
+ 'datetime' => $videoDateTime,
+ 'timestamp' => strtotime($videoDateTime),
+ 'size' => filesize($video),
+ 'size_mb' => round(filesize($video) / (1024 * 1024), 2)
+ ];
+ }
+ }
+ }
+}
+
+// Sortieren nach Datum/Zeit (neueste zuerst)
+usort($results['daily_videos'], fn($a, $b) => $b['timestamp'] - $a['timestamp']);
+usort($results['ai_videos'], fn($a, $b) => $b['timestamp'] - $a['timestamp']);
+
+// Limit anwenden
+$results['daily_videos'] = array_slice($results['daily_videos'], 0, $limit);
+$results['ai_videos'] = array_slice($results['ai_videos'], 0, $limit);
+
+// Statistiken
+$results['stats'] = [
+ 'total_daily' => count($results['daily_videos']),
+ 'total_ai' => count($results['ai_videos']),
+ 'total' => count($results['daily_videos']) + count($results['ai_videos'])
+];
+
+$results['filters'] = [
+ 'date' => $date,
+ 'time' => $time,
+ 'from' => $fromDate,
+ 'to' => $toDate,
+ 'time_from' => $timeFrom,
+ 'time_to' => $timeTo,
+ 'type' => $type,
+ 'ai_category' => $aiCategory
+];
+
+$results['success'] = true;
+
+echo json_encode($results, JSON_PRETTY_PRINT);
diff --git a/aurora-livecam/index.php b/aurora-livecam/index.php
index 0352a2a..d8b3f94 100644
--- a/aurora-livecam/index.php
+++ b/aurora-livecam/index.php
@@ -1310,7 +1310,7 @@ class AdminManager {
// Weather Settings
$output .= '';
- $output .= '
🌤️ Wetter-Widget (Open-Meteo - kostenlos, kein API-Key nötig)
';
+ $output .= '
🌤️ Wetter-Widget (Open-Meteo kostenlos, OpenWeatherMap optional)
';
$output .= '
';
$output .= '
Wetter-Widget anzeigen';
@@ -1325,7 +1325,14 @@ class AdminManager {
// API-KEY FELD KOMPLETT ENTFERNT
$output .= '
';
- $output .= '
Standort (Anzeigename)';
+ $output .= '
API Key (OpenWeatherMap, optional)';
+ $output .= '
';
+ $output .= '';
+ $output .= '
';
+ $output .= '
';
+
+ $output .= '
';
+ $output .= '
Standort (Stadt,Land)';
$output .= '
';
$output .= '';
$output .= '
';
@@ -2843,6 +2850,39 @@ body.theme-neo footer {
Videoarchiv Tagesvideos
+
+
+
+ 🔍 Suche nach Datum/Uhrzeit
+
+
+
+
+
displayVisualCalendar();
diff --git a/phase_locked_vocoder.py b/phase_locked_vocoder.py
new file mode 100644
index 0000000..5e1c61a
--- /dev/null
+++ b/phase_locked_vocoder.py
@@ -0,0 +1,215 @@
+"""
+Phase-Locked Timestretcher
+==========================
+
+High-quality offline time-stretching using a phase-locked phase vocoder.
+This approach keeps the original spectral texture by propagating peak phases
+and locking surrounding bins to preserve vertical phase coherence.
+
+Usage:
+ python phase_locked_vocoder.py input.wav output.wav 10.0
+"""
+
+from __future__ import annotations
+
+import argparse
+from dataclasses import dataclass
+from typing import Tuple
+
+import numpy as np
+from scipy import signal
+
+try:
+ import soundfile as sf
+except ImportError: # pragma: no cover - optional dependency
+ sf = None
+
+
+@dataclass
+class StretchConfig:
+ stretch_factor: float = 10.0
+ window_size: int = 4096
+ hop_size: int = 1024
+ peak_threshold_db: float = -60.0
+ peak_min_distance: int = 3
+
+
+def stft(audio: np.ndarray, window_size: int, hop_size: int) -> np.ndarray:
+ window = signal.windows.hann(window_size, sym=False)
+ n_frames = 1 + (len(audio) - window_size) // hop_size
+ frames = np.lib.stride_tricks.as_strided(
+ audio,
+ shape=(n_frames, window_size),
+ strides=(audio.strides[0] * hop_size, audio.strides[0]),
+ writeable=False,
+ )
+ windowed = frames * window[None, :]
+ return np.fft.rfft(windowed, axis=1).T
+
+
+def istft(stft_matrix: np.ndarray, window_size: int, hop_size: int, length: int) -> np.ndarray:
+ window = signal.windows.hann(window_size, sym=False)
+ n_frames = stft_matrix.shape[1]
+ output = np.zeros(hop_size * (n_frames - 1) + window_size)
+ window_sums = np.zeros_like(output)
+
+ for i in range(n_frames):
+ frame = np.fft.irfft(stft_matrix[:, i], n=window_size)
+ start = i * hop_size
+ output[start:start + window_size] += frame * window
+ window_sums[start:start + window_size] += window**2
+
+ nonzero = window_sums > 1e-8
+ output[nonzero] /= window_sums[nonzero]
+ return output[:length]
+
+
+def detect_peaks(magnitude: np.ndarray, threshold_db: float, min_distance: int) -> np.ndarray:
+ mag_db = 20 * np.log10(magnitude + 1e-12)
+ candidates = np.where(
+ (mag_db[1:-1] > threshold_db)
+ & (mag_db[1:-1] > mag_db[:-2])
+ & (mag_db[1:-1] > mag_db[2:])
+ )[0] + 1
+
+ if candidates.size == 0:
+ return np.array([], dtype=int)
+
+ # Enforce minimum distance between peaks
+ peaks = [candidates[0]]
+ for idx in candidates[1:]:
+ if idx - peaks[-1] >= min_distance:
+ peaks.append(idx)
+ return np.array(peaks, dtype=int)
+
+
+def phase_locked_vocoder(
+ stft_matrix: np.ndarray,
+ hop_size: int,
+ stretch_factor: float,
+ peak_threshold_db: float,
+ peak_min_distance: int,
+) -> np.ndarray:
+ n_bins, n_frames = stft_matrix.shape
+ if n_frames < 2:
+ return stft_matrix
+
+ time_steps = np.arange(0, n_frames - 1, 1 / stretch_factor)
+ output = np.zeros((n_bins, len(time_steps)), dtype=np.complex128)
+
+ phase_acc = np.angle(stft_matrix[:, 0])
+ expected_phase = 2 * np.pi * hop_size * np.arange(n_bins) / (2 * (n_bins - 1))
+
+ for t, step in enumerate(time_steps):
+ idx = int(np.floor(step))
+ frac = step - idx
+ if idx + 1 >= n_frames:
+ break
+
+ mag1 = np.abs(stft_matrix[:, idx])
+ mag2 = np.abs(stft_matrix[:, idx + 1])
+ mag = (1 - frac) * mag1 + frac * mag2
+
+ phase1 = np.angle(stft_matrix[:, idx])
+ phase2 = np.angle(stft_matrix[:, idx + 1])
+
+ phase_diff = phase2 - phase1 - expected_phase
+ phase_diff = (phase_diff + np.pi) % (2 * np.pi) - np.pi
+ true_freq = expected_phase + phase_diff
+ phase_acc += true_freq
+
+ peaks = detect_peaks(mag, threshold_db=peak_threshold_db, min_distance=peak_min_distance)
+ if peaks.size == 0:
+ output[:, t] = mag * np.exp(1j * phase_acc)
+ continue
+
+ output_phase = phase_acc.copy()
+ peak_phases = phase_acc[peaks]
+ analysis_phases = phase1
+
+ # Determine regions between peaks
+ boundaries = [0]
+ boundaries += [int((peaks[i] + peaks[i + 1]) / 2) for i in range(len(peaks) - 1)]
+ boundaries.append(n_bins - 1)
+
+ for i, peak in enumerate(peaks):
+ start = boundaries[i]
+ end = boundaries[i + 1]
+ if end <= start:
+ continue
+ relative_phase = analysis_phases[start:end + 1] - analysis_phases[peak]
+ output_phase[start:end + 1] = peak_phases[i] + relative_phase
+
+ output[:, t] = mag * np.exp(1j * output_phase)
+
+ return output
+
+
+def stretch_audio(audio: np.ndarray, sample_rate: int, config: StretchConfig) -> np.ndarray:
+ if audio.ndim > 1:
+ audio = np.mean(audio, axis=1)
+
+ audio = audio.astype(np.float64)
+ audio /= np.max(np.abs(audio)) + 1e-12
+
+ if len(audio) < config.window_size:
+ raise ValueError("Audio is shorter than the analysis window.")
+
+ padded = np.pad(audio, (config.window_size // 2, config.window_size // 2), mode="reflect")
+ stft_matrix = stft(padded, config.window_size, config.hop_size)
+
+ stretched_stft = phase_locked_vocoder(
+ stft_matrix,
+ hop_size=config.hop_size,
+ stretch_factor=config.stretch_factor,
+ peak_threshold_db=config.peak_threshold_db,
+ peak_min_distance=config.peak_min_distance,
+ )
+
+ output_length = int(len(audio) * config.stretch_factor)
+ output = istft(stretched_stft, config.window_size, config.hop_size, output_length + config.window_size)
+
+ output = output[config.window_size // 2:config.window_size // 2 + output_length]
+ peak = np.max(np.abs(output))
+ if peak > 0:
+ output = 0.95 * output / peak
+ return output
+
+
+def stretch_file(input_path: str, output_path: str, config: StretchConfig) -> None:
+ if sf is None:
+ raise RuntimeError("soundfile is required for file IO. Install with `pip install soundfile`.")
+
+ audio, sr = sf.read(input_path)
+ result = stretch_audio(audio, sr, config)
+ sf.write(output_path, result, sr)
+
+
+def parse_args() -> Tuple[str, str, StretchConfig]:
+ parser = argparse.ArgumentParser(description="Phase-locked time-stretching")
+ parser.add_argument("input", help="Input WAV file")
+ parser.add_argument("output", help="Output WAV file")
+ parser.add_argument("stretch", type=float, help="Stretch factor (e.g., 10.0)")
+ parser.add_argument("--window", type=int, default=4096)
+ parser.add_argument("--hop", type=int, default=1024)
+ parser.add_argument("--peak-db", type=float, default=-60.0)
+ parser.add_argument("--peak-distance", type=int, default=3)
+ args = parser.parse_args()
+
+ config = StretchConfig(
+ stretch_factor=args.stretch,
+ window_size=args.window,
+ hop_size=args.hop,
+ peak_threshold_db=args.peak_db,
+ peak_min_distance=args.peak_distance,
+ )
+ return args.input, args.output, config
+
+
+def main() -> None:
+ input_path, output_path, config = parse_args()
+ stretch_file(input_path, output_path, config)
+
+
+if __name__ == "__main__":
+ main()