AvianVisitors: BirdNET-Pi collage frontend overlay

Adds avian/ on top of upstream BirdNET-Pi:
- frontend/    static collage UI (mask-packed bird tiles, sized by detection count)
- assets/      ~450 bundled kachō-e illustrations + cutouts + binary masks
- api/         PHP shims for recent/lifelist/firstseen/timeseries JSON
- scripts/     installer + Gemini pregen + editable prompt template
- caddy/       snippet mounting /collage on existing Caddy
- forwarding/  optional HA / MQTT / Cloudflare configs

Default install: http://birdnet.local/collage/ with no auth.

Upstream BirdNET-Pi README preserved at README.upstream.md.
This commit is contained in:
Twarner491
2026-05-28 10:14:25 -07:00
parent 88985a3a6f
commit d0ad1454c4
705 changed files with 2276 additions and 154 deletions
+183
View File
@@ -0,0 +1,183 @@
<?php
// /home/monalisa/BirdSongs/Extracted/api.php — JSON facade over BirdNET-Pi's
// birds.db, queryable by the bird.onethreenine.net Cloudflare Worker.
//
// Lives in the Caddy file-server root (NOT in BirdNET-Pi/scripts/) so the
// Sunday auto-update doesn't clobber it. Reachable as /api.php on the Pi.
//
// Auth: callers must send X-BirdNET-Proxy-Token matching the Caddy gate set
// up earlier — Caddy 403s anything missing it before this script runs, so we
// inherit that protection for free. The Cloudflare Worker is the only thing
// that ever sets the header.
//
// Endpoints (?action=...):
// stats — totals: detections, unique species, today, last hour, etc.
// lifelist — every species with first_seen, last_seen, total_count
// recent — &hours=N (default 24): every detection in the window
// species — &sci=<sci_name>: detail page for one species
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: public, max-age=30');
$DB_PATH = '/home/monalisa/BirdNET-Pi/scripts/birds.db';
if (!file_exists($DB_PATH)) {
http_response_code(503);
echo json_encode(['error' => 'birds.db not found']);
exit;
}
try {
$db = new SQLite3($DB_PATH, SQLITE3_OPEN_READONLY);
$db->busyTimeout(2000);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['error' => 'db open failed']);
exit;
}
function rows(SQLite3 $db, string $sql, array $bind = []): array {
$stmt = $db->prepare($sql);
foreach ($bind as $k => $v) $stmt->bindValue($k, $v);
$res = $stmt->execute();
$out = [];
while ($r = $res->fetchArray(SQLITE3_ASSOC)) $out[] = $r;
return $out;
}
function one(SQLite3 $db, string $sql, array $bind = []) {
$r = rows($db, $sql, $bind);
return $r[0] ?? null;
}
$action = $_GET['action'] ?? 'stats';
switch ($action) {
case 'stats': {
$total = (int)(one($db, 'SELECT COUNT(*) AS n FROM detections')['n'] ?? 0);
$species = (int)(one($db, 'SELECT COUNT(DISTINCT Sci_Name) AS n FROM detections')['n'] ?? 0);
$today = (int)(one($db, "SELECT COUNT(*) AS n FROM detections WHERE Date = DATE('now','localtime')")['n'] ?? 0);
$todaySpec = (int)(one($db, "SELECT COUNT(DISTINCT Sci_Name) AS n FROM detections WHERE Date = DATE('now','localtime')")['n'] ?? 0);
$lastHour = (int)(one($db, "SELECT COUNT(*) AS n FROM detections WHERE Date = DATE('now','localtime') AND Time >= TIME('now','localtime','-1 hour')")['n'] ?? 0);
$week = (int)(one($db, "SELECT COUNT(*) AS n FROM detections WHERE Date >= DATE('now','localtime','-7 day')")['n'] ?? 0);
$weekSpec = (int)(one($db, "SELECT COUNT(DISTINCT Sci_Name) AS n FROM detections WHERE Date >= DATE('now','localtime','-7 day')")['n'] ?? 0);
$first = one($db, 'SELECT MIN(Date) AS d FROM detections');
echo json_encode([
'totals' => ['detections' => $total, 'species' => $species],
'today' => ['detections' => $today, 'species' => $todaySpec],
'last_hour' => ['detections' => $lastHour],
'week' => ['detections' => $week, 'species' => $weekSpec],
'started' => $first['d'] ?? null,
'as_of' => date('c'),
]);
break;
}
case 'lifelist': {
$rs = rows($db,
"SELECT Sci_Name AS sci, Com_Name AS com, MIN(Date||' '||Time) AS first_seen, "
. " MAX(Date||' '||Time) AS last_seen, COUNT(*) AS total, MAX(Confidence) AS best_conf "
. "FROM detections GROUP BY Sci_Name ORDER BY first_seen ASC"
);
echo json_encode(['species' => $rs, 'as_of' => date('c')]);
break;
}
case 'recent': {
// Cap raised to 1,000,000 hours (~114 years) so the frontend's
// "ALL" button can effectively turn off the time filter without
// needing a separate code path.
$hours = max(1, min(1000000, (int)($_GET['hours'] ?? 24)));
// species-collapsed view: one row per species seen in the window,
// with the file of its highest-confidence detection inside the window.
$rs = rows($db,
"SELECT Sci_Name AS sci, Com_Name AS com, COUNT(*) AS n, MAX(Confidence) AS best_conf, "
. " MAX(Date||' '||Time) AS last_seen "
. "FROM detections "
. "WHERE (julianday('now','localtime') - julianday(Date||' '||Time)) * 24 <= :hrs "
. "GROUP BY Sci_Name ORDER BY last_seen DESC",
[':hrs' => $hours]
);
// for each row, attach the file of the top-confidence detection in the window
foreach ($rs as &$r) {
$best = one($db,
"SELECT File_Name AS file, Date AS d, Time AS t, Confidence AS conf "
. "FROM detections "
. "WHERE Sci_Name = :sn "
. "AND (julianday('now','localtime') - julianday(Date||' '||Time)) * 24 <= :hrs "
. "ORDER BY Confidence DESC LIMIT 1",
[':sn' => $r['sci'], ':hrs' => $hours]
);
$r['top_file'] = $best['file'] ?? null;
$r['top_at'] = isset($best['d']) ? ($best['d'].' '.$best['t']) : null;
}
echo json_encode(['hours' => $hours, 'species' => $rs, 'as_of' => date('c')]);
break;
}
case 'species': {
$sci = $_GET['sci'] ?? '';
if ($sci === '') { http_response_code(400); echo json_encode(['error' => 'sci= required']); break; }
$detections = rows($db,
"SELECT Date AS d, Time AS t, File_Name AS file, Confidence AS conf "
. "FROM detections WHERE Sci_Name = :sn ORDER BY Date DESC, Time DESC LIMIT 500",
[':sn' => $sci]
);
$summary = one($db,
"SELECT Com_Name AS com, COUNT(*) AS total, MIN(Date||' '||Time) AS first_seen, "
. " MAX(Date||' '||Time) AS last_seen, MAX(Confidence) AS best_conf "
. "FROM detections WHERE Sci_Name = :sn",
[':sn' => $sci]
);
echo json_encode(['sci' => $sci, 'summary' => $summary, 'detections' => $detections]);
break;
}
case 'timeseries': {
// Aggregated time-bucketed counts for the stats charts.
// daily — last $days days, detections + unique species per day
// by_hour — detections grouped by hour of day, last 30 days
// The frontend backfills missing dates with zero — sparse data days
// are otherwise dropped by the GROUP BY.
$days = max(1, min(90, (int)($_GET['days'] ?? 30)));
$daily = rows($db,
"SELECT Date AS date, COUNT(*) AS detections, COUNT(DISTINCT Sci_Name) AS species "
. "FROM detections "
. "WHERE Date >= DATE('now','localtime','-".($days - 1)." day') "
. "GROUP BY Date ORDER BY Date"
);
$by_hour = rows($db,
"SELECT CAST(strftime('%H', Time) AS INT) AS hour, COUNT(*) AS detections "
. "FROM detections "
. "WHERE Date >= DATE('now','localtime','-30 day') "
. "GROUP BY hour ORDER BY hour"
);
echo json_encode([
'days' => $days,
'daily' => $daily,
'by_hour' => $by_hour,
'as_of' => date('c'),
]);
break;
}
case 'firstseen': {
// Most recent additions to the life list — first detection per
// species, sorted by first_seen DESC. Powers the "First Detections"
// section on the stats view.
$limit = max(1, min(50, (int)($_GET['limit'] ?? 10)));
$rs = rows($db,
"SELECT Sci_Name AS sci, Com_Name AS com, MIN(Date||' '||Time) AS first_seen, "
. " COUNT(*) AS total "
. "FROM detections GROUP BY Sci_Name ORDER BY first_seen DESC LIMIT :lim",
[':lim' => $limit]
);
echo json_encode(['species' => $rs, 'as_of' => date('c')]);
break;
}
default:
http_response_code(404);
echo json_encode(['error' => 'unknown action']);
}
+134
View File
@@ -0,0 +1,134 @@
<?php
// /home/monalisa/BirdSongs/Extracted/cutout.php — dynamic background-removed
// bird-photo facade. Called by the bird.onethreenine.net Cloudflare Worker
// (which fronts /api/img?sci=<name>).
//
// Lives in the Caddy file-server root so the BirdNET-Pi auto-update doesn't
// touch it. Each new species hits this once: download Wikipedia/Macaulay
// image → rembg via /usr/local/bin/rembg-cli → cache transparent PNG to
// disk under cutouts/. Subsequent requests are instant readfile() from cache.
//
// Auth: the Caddy site block 403s anything missing X-BirdNET-Proxy-Token at
// the top, so this script inherits the Worker-only access guarantee.
//
// Pre-reqs (run once via /install/rembg-setup.sh):
// - /home/monalisa/rembg-env/bin/rembg (Python venv)
// - /usr/local/bin/rembg-cli (wrapper)
// - /home/monalisa/BirdSongs/Extracted/cutouts/ (cache dir, monalisa-owned)
declare(strict_types=1);
$sci = trim((string)($_GET['sci'] ?? ''));
if ($sci === '') {
http_response_code(400);
echo 'sci required';
exit;
}
// Slugify scientific name for the cache filename.
$slug = preg_replace('/[^a-z0-9]+/', '-', strtolower($sci));
$slug = trim((string)$slug, '-');
$cacheDir = '/home/monalisa/BirdSongs/Extracted/cutouts';
$cachePath = "$cacheDir/$slug.png";
function serve_png(string $path): void {
header('Content-Type: image/png');
header('Cache-Control: public, max-age=2592000');
header('Content-Length: ' . filesize($path));
readfile($path);
exit;
}
// Cache hit — short-circuit.
if (is_file($cachePath) && filesize($cachePath) > 1024) {
serve_png($cachePath);
}
// Make sure the cache dir exists (first run).
if (!is_dir($cacheDir)) @mkdir($cacheDir, 0755, true);
// Resolve a source image URL. Wikipedia summary first (free, fast,
// no auth, has a clean originalimage for most birds).
$ctx = stream_context_create([
'http' => [
'header' => "User-Agent: apartment-birds/1.0 (twarner491@gmail.com)\r\n",
'timeout' => 12,
],
]);
$wpUrl = 'https://en.wikipedia.org/api/rest_v1/page/summary/' . rawurlencode($sci);
$wpJson = @file_get_contents($wpUrl, false, $ctx);
$srcUrl = null;
if ($wpJson !== false) {
$j = json_decode($wpJson, true);
$srcUrl = $j['originalimage']['source'] ?? $j['thumbnail']['source'] ?? null;
}
if (!$srcUrl) {
http_response_code(404);
echo 'no upstream image for ' . htmlspecialchars($sci);
exit;
}
// Download the source image.
$imgBytes = @file_get_contents($srcUrl, false, $ctx);
if (!$imgBytes || strlen($imgBytes) < 1024) {
http_response_code(503);
echo 'failed to fetch source image';
exit;
}
// Run rembg via the wrapper. We use temp files because rembg's CLI prefers
// real paths. u2netp is the lightweight model (~50MB peak RAM) — important
// on the Pi 3B+ (1GB total RAM). Call with --post-process-mask to clean
// up edges.
$tmpIn = tempnam('/tmp', 'rembg-in-') . '.jpg';
$tmpOut = tempnam('/tmp', 'rembg-out-') . '.png';
file_put_contents($tmpIn, $imgBytes);
$cmd = sprintf(
'/usr/local/bin/rembg-cli i -m u2netp -ppm %s %s 2>&1',
escapeshellarg($tmpIn),
escapeshellarg($tmpOut)
);
$out = shell_exec($cmd);
@unlink($tmpIn);
if (!is_file($tmpOut) || filesize($tmpOut) < 1024) {
@unlink($tmpOut);
http_response_code(500);
header('Content-Type: text/plain');
echo "rembg failed:\n" . ($out ?? '(no output)');
exit;
}
// 1. Alpha-crop to bounding box so each PNG == the bird's actual shape
// (no transparent padding around it). Lets the layout pack tiles tight.
// 2. Resize down to a max edge of 800px so the cache stays small.
$im = @imagecreatefrompng($tmpOut);
if ($im !== false) {
$cropped = @imagecropauto($im, IMG_CROP_TRANSPARENT);
if ($cropped !== false) {
imagedestroy($im);
$im = $cropped;
}
$w = imagesx($im); $h = imagesy($im);
$max = 800;
if ($w > $max || $h > $max) {
$scale = $max / max($w, $h);
$nw = (int)($w * $scale); $nh = (int)($h * $scale);
$resized = imagecreatetruecolor($nw, $nh);
imagealphablending($resized, false);
imagesavealpha($resized, true);
imagecopyresampled($resized, $im, 0, 0, 0, 0, $nw, $nh, $w, $h);
imagedestroy($im);
$im = $resized;
}
imagealphablending($im, false);
imagesavealpha($im, true);
imagepng($im, $tmpOut, 6);
imagedestroy($im);
}
// Cache + serve.
copy($tmpOut, $cachePath);
@unlink($tmpOut);
serve_png($cachePath);
+176
View File
@@ -0,0 +1,176 @@
<?php
// /home/monalisa/BirdSongs/Extracted/recording.php — serves the most-recent
// detection mp3 for a given scientific name. Called by the Cloudflare Worker
// at /api/recording?sci=<name>.
//
// BirdNET-Pi writes audio + spectrograms to
// ~/BirdSongs/Extracted/By_Date/YYYY-MM-DD/<Common_Name>/<base>.mp3
// (with a matching .png next to it). Common_Name is the SPACE-stripped
// English common name (e.g. "Anna's_Hummingbird"), NOT the scientific name.
// We resolve sci → common via the same lookup the Pi's web UI uses
// (birds.json under /scripts/) and then walk the directory tree newest-first.
//
// Auth: the Caddy site block 403s anything missing X-BirdNET-Proxy-Token.
declare(strict_types=1);
$sci = trim((string)($_GET['sci'] ?? ''));
$file = trim((string)($_GET['file'] ?? ''));
if ($sci === '' && $file === '') {
http_response_code(400);
echo 'sci or file required';
exit;
}
// Reject any sci-name that isn't a clean Genus species[ subspecies[ tri]]
// pattern. resolve_common() below falls back to str_replace(' ', '_', $sci)
// when there's no birds.json match — without this guard, `?sci=../etc` would
// flow through unmodified into a filesystem path.
if ($sci !== '' && !preg_match('/^[A-Za-z]{2,40}(?:[ ][a-z]{2,40}){1,3}$/', $sci)) {
http_response_code(400);
echo 'invalid sci';
exit;
}
$BY_DATE = '/home/monalisa/BirdSongs/Extracted/By_Date';
// ---- Direct-by-file lookup ----
// Used by the atlas detail modal to play any past recording.
// Filename schema (BirdNET-Pi):
// <Common_Name>-<conf>-<YYYY-MM-DD>-birdnet-<HH-MM-SS>.mp3
// We pull the date out of the filename to locate the species
// directory under By_Date/. Whitelisted character set keeps this
// safe against path-traversal payloads.
if ($file !== '') {
if (!preg_match('/^[A-Za-z0-9_.:-]+\.mp3$/', $file)) {
http_response_code(400);
echo 'invalid file name';
exit;
}
// Extract the YYYY-MM-DD from the filename if present.
$date = null;
if (preg_match('/(\d{4}-\d{2}-\d{2})/', $file, $m)) $date = $m[1];
// The species directory is the prefix before the first dash-digit.
// Try a couple of strategies: explicit species_dir param, dir from file
// prefix, then a broader recursive search as fallback.
$candidates = [];
if ($date) {
// Look in that specific date dir across every species subdir.
$dayDir = "$BY_DATE/$date";
if (is_dir($dayDir)) {
foreach (scandir($dayDir) as $sub) {
if ($sub[0] === '.') continue;
$p = "$dayDir/$sub/$file";
if (is_file($p)) { $candidates[] = $p; break; }
}
}
}
if (!$candidates) {
// Fall back to scanning every date dir (slower).
if (is_dir($BY_DATE)) {
foreach (scandir($BY_DATE) as $d) {
if ($d[0] === '.') continue;
$dayDir = "$BY_DATE/$d";
if (!is_dir($dayDir)) continue;
foreach (scandir($dayDir) as $sub) {
if ($sub[0] === '.') continue;
$p = "$dayDir/$sub/$file";
if (is_file($p)) { $candidates[] = $p; break 2; }
}
}
}
}
if (!$candidates || filesize($candidates[0]) < 64) {
http_response_code(404);
echo 'recording not found';
exit;
}
$path = $candidates[0];
header('Content-Type: audio/mpeg');
header('Content-Length: ' . filesize($path));
header('Cache-Control: public, max-age=86400');
header('Accept-Ranges: bytes');
readfile($path);
exit;
}
$BIRDS_JSON_CANDIDATES = [
'/home/monalisa/BirdNET-Pi/scripts/birds.json',
'/home/monalisa/BirdNET-Pi/model/labels.txt',
];
// ---- Resolve scientific name → common name (with underscores) ----
function resolve_common(string $sci): ?string {
// Try birds.json first (preferred — has clean sci/com pairs).
foreach (['/home/monalisa/BirdNET-Pi/scripts/birds.json'] as $f) {
if (is_readable($f)) {
$list = json_decode((string)file_get_contents($f), true);
if (is_array($list)) {
foreach ($list as $row) {
if (!is_array($row)) continue;
$rowSci = $row['sci'] ?? $row['scientific'] ?? $row['scientificName'] ?? '';
$rowCom = $row['com'] ?? $row['common'] ?? $row['commonName'] ?? '';
if (strcasecmp(trim((string)$rowSci), $sci) === 0 && $rowCom) {
return str_replace(' ', '_', (string)$rowCom);
}
}
}
}
}
// Fallback: labels.txt has "<sci>_<com>" or "<sci>, <com>" per line.
$labels = '/home/monalisa/BirdNET-Pi/model/labels.txt';
if (is_readable($labels)) {
foreach (file($labels, FILE_IGNORE_NEW_LINES) as $line) {
if (strpos($line, '_') !== false) {
[$s, $c] = explode('_', $line, 2);
if (strcasecmp(trim($s), $sci) === 0) {
return str_replace(' ', '_', trim($c));
}
}
}
}
return null;
}
$common = resolve_common($sci);
if ($common === null) {
// Last-ditch: try the scientific name itself, with spaces → underscores.
// (Some BirdNET dirs are keyed by sci name.)
$common = str_replace(' ', '_', $sci);
}
// ---- Find newest matching file ----
// Walk By_Date/* newest-first; inside each date dir, look for a
// subdirectory named exactly $common, return the newest .mp3 inside.
function newest_recording(string $rootDir, string $common): ?string {
if (!is_dir($rootDir)) return null;
$dates = scandir($rootDir, SCANDIR_SORT_DESCENDING);
if (!$dates) return null;
foreach ($dates as $date) {
if ($date[0] === '.') continue;
$speciesDir = "$rootDir/$date/$common";
if (!is_dir($speciesDir)) continue;
$files = scandir($speciesDir, SCANDIR_SORT_DESCENDING);
if (!$files) continue;
foreach ($files as $f) {
if (substr($f, -4) === '.mp3') {
return "$speciesDir/$f";
}
}
}
return null;
}
$path = newest_recording($BY_DATE, $common);
if ($path === null || !is_file($path) || filesize($path) < 64) {
http_response_code(404);
echo 'no recording for ' . htmlspecialchars($sci);
exit;
}
// ---- Serve ----
header('Content-Type: audio/mpeg');
header('Content-Length: ' . filesize($path));
header('Cache-Control: public, max-age=60');
header('Accept-Ranges: bytes');
readfile($path);
+155
View File
@@ -0,0 +1,155 @@
<?php
// /home/monalisa/BirdSongs/Extracted/spectrogram.php — serves the
// spectrogram PNG that BirdNET-Pi generates alongside each detection mp3.
// Same lookup logic as recording.php (find the matching file under
// By_Date/<date>/<Common_Name>/) — just .png instead of .mp3.
//
// Endpoints:
// ?sci=<sci_name> → newest spectrogram for that species
// ?file=<original-base>.mp3 → spectrogram next to that specific
// recording (atlas modal uses this so the
// strip below each play button is the
// spectrogram for that recording, not
// "the most recent" — they can differ).
declare(strict_types=1);
$sci = trim((string)($_GET['sci'] ?? ''));
$file = trim((string)($_GET['file'] ?? ''));
if ($sci === '' && $file === '') {
http_response_code(400);
echo 'sci or file required';
exit;
}
// Reject any sci-name that isn't a clean Genus species[ subspecies[ tri]]
// pattern. Same defence-in-depth check as recording.php.
if ($sci !== '' && !preg_match('/^[A-Za-z]{2,40}(?:[ ][a-z]{2,40}){1,3}$/', $sci)) {
http_response_code(400);
echo 'invalid sci';
exit;
}
$BY_DATE = '/home/monalisa/BirdSongs/Extracted/By_Date';
// ---- Direct-by-file lookup ----
// BirdNET-Pi writes <base>.mp3 and <base>.png next to each other under
// By_Date/<date>/<Common_Name>/. So we accept the mp3 filename, swap
// the extension, and search the same way recording.php does.
if ($file !== '') {
if (!preg_match('/^[A-Za-z0-9_.:-]+\.(mp3|png)$/', $file)) {
http_response_code(400);
echo 'invalid file name';
exit;
}
// BirdNET-Pi names the spectrogram as the FULL mp3 filename plus
// ".png" — e.g. "American_Crow-82-…-20:25:29.mp3" pairs with
// "American_Crow-82-…-20:25:29.mp3.png" (not "…-20:25:29.png").
// Accept either form gracefully.
if (substr($file, -4) === '.png') {
$png = $file;
} else {
$png = $file . '.png';
}
$date = null;
if (preg_match('/(\d{4}-\d{2}-\d{2})/', $png, $m)) $date = $m[1];
$candidates = [];
if ($date) {
$dayDir = "$BY_DATE/$date";
if (is_dir($dayDir)) {
foreach (scandir($dayDir) as $sub) {
if ($sub[0] === '.') continue;
$p = "$dayDir/$sub/$png";
if (is_file($p)) { $candidates[] = $p; break; }
}
}
}
if (!$candidates) {
if (is_dir($BY_DATE)) {
foreach (scandir($BY_DATE) as $d) {
if ($d[0] === '.') continue;
$dayDir = "$BY_DATE/$d";
if (!is_dir($dayDir)) continue;
foreach (scandir($dayDir) as $sub) {
if ($sub[0] === '.') continue;
$p = "$dayDir/$sub/$png";
if (is_file($p)) { $candidates[] = $p; break 2; }
}
}
}
}
if (!$candidates || filesize($candidates[0]) < 64) {
http_response_code(404);
echo 'spectrogram not found';
exit;
}
$path = $candidates[0];
header('Content-Type: image/png');
header('Content-Length: ' . filesize($path));
header('Cache-Control: public, max-age=86400');
readfile($path);
exit;
}
function resolve_common(string $sci): ?string {
$f = '/home/monalisa/BirdNET-Pi/scripts/birds.json';
if (is_readable($f)) {
$list = json_decode((string)file_get_contents($f), true);
if (is_array($list)) {
foreach ($list as $row) {
if (!is_array($row)) continue;
$rowSci = $row['sci'] ?? $row['scientific'] ?? $row['scientificName'] ?? '';
$rowCom = $row['com'] ?? $row['common'] ?? $row['commonName'] ?? '';
if (strcasecmp(trim((string)$rowSci), $sci) === 0 && $rowCom) {
return str_replace(' ', '_', (string)$rowCom);
}
}
}
}
$labels = '/home/monalisa/BirdNET-Pi/model/labels.txt';
if (is_readable($labels)) {
foreach (file($labels, FILE_IGNORE_NEW_LINES) as $line) {
if (strpos($line, '_') !== false) {
[$s, $c] = explode('_', $line, 2);
if (strcasecmp(trim($s), $sci) === 0) {
return str_replace(' ', '_', trim($c));
}
}
}
}
return null;
}
$common = resolve_common($sci) ?? str_replace(' ', '_', $sci);
function newest_spectrogram(string $rootDir, string $common): ?string {
if (!is_dir($rootDir)) return null;
$dates = scandir($rootDir, SCANDIR_SORT_DESCENDING);
if (!$dates) return null;
foreach ($dates as $date) {
if ($date[0] === '.') continue;
$speciesDir = "$rootDir/$date/$common";
if (!is_dir($speciesDir)) continue;
$files = scandir($speciesDir, SCANDIR_SORT_DESCENDING);
if (!$files) continue;
foreach ($files as $f) {
if (substr($f, -4) === '.png') {
return "$speciesDir/$f";
}
}
}
return null;
}
$path = newest_spectrogram($BY_DATE, $common);
if ($path === null || !is_file($path) || filesize($path) < 64) {
http_response_code(404);
echo 'no spectrogram for ' . htmlspecialchars($sci);
exit;
}
header('Content-Type: image/png');
header('Content-Length: ' . filesize($path));
header('Cache-Control: public, max-age=60');
readfile($path);
Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 679 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 493 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 635 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 556 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Some files were not shown because too many files have changed in this diff Show More