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.
@@ -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']);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
After Width: | Height: | Size: 342 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 424 KiB |
|
After Width: | Height: | Size: 403 KiB |
|
After Width: | Height: | Size: 406 KiB |
|
After Width: | Height: | Size: 389 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 342 KiB |
|
After Width: | Height: | Size: 283 KiB |
|
After Width: | Height: | Size: 364 KiB |
|
After Width: | Height: | Size: 416 KiB |
|
After Width: | Height: | Size: 618 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 414 KiB |
|
After Width: | Height: | Size: 299 KiB |
|
After Width: | Height: | Size: 453 KiB |
|
After Width: | Height: | Size: 679 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 383 KiB |
|
After Width: | Height: | Size: 275 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 549 KiB |
|
After Width: | Height: | Size: 622 KiB |
|
After Width: | Height: | Size: 447 KiB |
|
After Width: | Height: | Size: 351 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 558 KiB |
|
After Width: | Height: | Size: 413 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 467 KiB |
|
After Width: | Height: | Size: 345 KiB |
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 427 KiB |
|
After Width: | Height: | Size: 554 KiB |
|
After Width: | Height: | Size: 503 KiB |
|
After Width: | Height: | Size: 633 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 436 KiB |
|
After Width: | Height: | Size: 453 KiB |
|
After Width: | Height: | Size: 493 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 561 KiB |
|
After Width: | Height: | Size: 404 KiB |
|
After Width: | Height: | Size: 373 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 462 KiB |
|
After Width: | Height: | Size: 352 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 635 KiB |
|
After Width: | Height: | Size: 409 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 323 KiB |
|
After Width: | Height: | Size: 442 KiB |
|
After Width: | Height: | Size: 567 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 435 KiB |
|
After Width: | Height: | Size: 347 KiB |
|
After Width: | Height: | Size: 556 KiB |
|
After Width: | Height: | Size: 356 KiB |
|
After Width: | Height: | Size: 385 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 488 KiB |
|
After Width: | Height: | Size: 466 KiB |
|
After Width: | Height: | Size: 327 KiB |
|
After Width: | Height: | Size: 398 KiB |
|
After Width: | Height: | Size: 702 KiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 420 KiB |
|
After Width: | Height: | Size: 379 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 446 KiB |
|
After Width: | Height: | Size: 526 KiB |
|
After Width: | Height: | Size: 352 KiB |
|
After Width: | Height: | Size: 457 KiB |
|
After Width: | Height: | Size: 490 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 341 KiB |
|
After Width: | Height: | Size: 290 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 569 KiB |
|
After Width: | Height: | Size: 499 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 316 KiB |
|
After Width: | Height: | Size: 562 KiB |
|
After Width: | Height: | Size: 479 KiB |
|
After Width: | Height: | Size: 340 KiB |