Add remaining relevant files (auto)
This commit is contained in:
parent
c2d64cb00a
commit
6b00b0d7fe
7 changed files with 649 additions and 1 deletions
57
api/enable_show.php
Normal file
57
api/enable_show.php
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
<?php
|
||||||
|
// API: enable_show.php - Toggle Show and (if authenticated) attempt to send a "Show enabled" push.
|
||||||
|
require __DIR__ . '/../auth.php';
|
||||||
|
mvlog_require_login();
|
||||||
|
|
||||||
|
// Web-server side handler to toggle "Show" for an MVLog job and send a web-push immediately if appropriate.
|
||||||
|
// Usage (POST): job=<job> show=1|0
|
||||||
|
|
||||||
|
$MVLOG_ROOT = dirname(__DIR__);
|
||||||
|
$job = $_POST['job'] ?? '';
|
||||||
|
$show = isset($_POST['show']) ? filter_var($_POST['show'], FILTER_VALIDATE_BOOLEAN) : true;
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
if (!preg_match('/^[0-9]{8}[-_][A-Za-z0-9._-]+$/', $job)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo 'invalid job';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$jobdir = "$MVLOG_ROOT/in-dir/$job";
|
||||||
|
if (!is_dir($jobdir)) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo 'job not found';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$enabled_file = "$jobdir/.movmaker-enabled";
|
||||||
|
$cache_file = "$MVLOG_ROOT/cache/push_notifications.json";
|
||||||
|
|
||||||
|
if ($show) {
|
||||||
|
file_put_contents($enabled_file, date(DATE_ATOM) . PHP_EOL, LOCK_EX);
|
||||||
|
|
||||||
|
// Attempt to send push (non-fatal). Uses lib/send_push.php
|
||||||
|
$sent = false;
|
||||||
|
if (is_file(__DIR__ . '/../lib/send_push.php')) {
|
||||||
|
include_once __DIR__ . '/../lib/send_push.php';
|
||||||
|
try {
|
||||||
|
$sent = try_send_show_notification($job, $jobdir, $MVLOG_ROOT, '/var/log/mvlog_notify.log');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log("[mvlog] notify exception for $job: " . $e->getMessage() . "\n", 3, '/var/log/mvlog_notify.log');
|
||||||
|
$sent = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error_log("[mvlog] send_push helper missing, cannot send notification for $job\n", 3, '/var/log/mvlog_notify.log');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sent) {
|
||||||
|
echo 'enabled';
|
||||||
|
} else {
|
||||||
|
echo 'enabled (no notification)';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
@unlink($enabled_file);
|
||||||
|
echo 'disabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
exit;
|
||||||
242
bin/debug_generate_data.sh
Executable file
242
bin/debug_generate_data.sh
Executable file
|
|
@ -0,0 +1,242 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
IN_DIR="${1:?Usage: $0 <input-dir> [\"Title\"] [\"Location\"] [max_frames]}"
|
||||||
|
TITLE="${2:-}"
|
||||||
|
LOCATION="${3:-}"
|
||||||
|
MAX_FRAMES="${4:-12}"
|
||||||
|
|
||||||
|
# Source system-wide Gemini API key if present so the webserver can run this as www-data.
|
||||||
|
if [ -f /etc/mvlog/gemini.env ]; then
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. /etc/mvlog/gemini.env
|
||||||
|
fi
|
||||||
|
|
||||||
|
: "${GEMINI_API_KEY:?Set GEMINI_API_KEY first in the environment}"
|
||||||
|
MODEL="${GEMINI_MODEL:-gemini-2.5-flash}"
|
||||||
|
|
||||||
|
TMPDIR="${TMPDIR:-$(mktemp -d)}"
|
||||||
|
FRAMES_DIR="$TMPDIR/frames"
|
||||||
|
mkdir -p "$FRAMES_DIR"
|
||||||
|
|
||||||
|
# Print TMPDIR for debugging when run interactively
|
||||||
|
echo "DEBUG: TMPDIR=$TMPDIR" >&2
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$TMPDIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# Collect media files (natural sort)
|
||||||
|
mapfile -t FILES < <(find "$IN_DIR" -maxdepth 1 -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' -o -iname '*.gif' -o -iname '*.mp4' -o -iname '*.mov' -o -iname '*.m4v' -o -iname '*.webm' -o -iname '*.mkv' \) -printf '%f\n' | sort -V)
|
||||||
|
|
||||||
|
if [ "${#FILES[@]}" -eq 0 ]; then
|
||||||
|
echo "No media files found in $IN_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
frame_count=0
|
||||||
|
|
||||||
|
# Helper: extract scaled JPEG from image
|
||||||
|
extract_image_frame() {
|
||||||
|
local src="$1" dst="$2"
|
||||||
|
ffmpeg -hide_banner -loglevel error -y -i "$src" -vf "scale=1024:-1" -q:v 4 "$dst"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Helper: extract frame from video at time
|
||||||
|
extract_video_frame_at() {
|
||||||
|
local src="$1" time="$2" dst="$3"
|
||||||
|
# Use -ss before -i for fast seek
|
||||||
|
ffmpeg -hide_banner -loglevel error -y -ss "$time" -i "$src" -frames:v 1 -vf "scale=1024:-1" -q:v 4 "$dst"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Process files: images -> one frame each; videos -> up to 3 frames evenly spaced (subject to MAX_FRAMES)
|
||||||
|
for f in "${FILES[@]}"; do
|
||||||
|
full="$IN_DIR/$f"
|
||||||
|
ext="${f##*.}"
|
||||||
|
ext_l="${ext,,}"
|
||||||
|
|
||||||
|
if [[ "$ext_l" =~ ^(mp4|mov|m4v|webm|mkv)$ ]]; then
|
||||||
|
# video: probe duration
|
||||||
|
duration=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$full" 2>/dev/null || echo 0)
|
||||||
|
duration=${duration:-0}
|
||||||
|
# choose frames per video
|
||||||
|
if awk "BEGIN{print ($duration >= 6)}" | grep -q 1; then
|
||||||
|
nframes=3
|
||||||
|
elif awk "BEGIN{print ($duration >= 3)}" | grep -q 1; then
|
||||||
|
nframes=2
|
||||||
|
else
|
||||||
|
nframes=1
|
||||||
|
fi
|
||||||
|
# limit to remaining slots
|
||||||
|
remaining=$((MAX_FRAMES - frame_count))
|
||||||
|
if [ "$remaining" -le 0 ]; then break; fi
|
||||||
|
if [ "$nframes" -gt "$remaining" ]; then nframes=$remaining; fi
|
||||||
|
|
||||||
|
# compute times and extract
|
||||||
|
i=1
|
||||||
|
while [ $i -le $nframes ]; do
|
||||||
|
# time = (i/(n+1)) * duration
|
||||||
|
time=$(awk -v d="$duration" -v i="$i" -v n="$nframes" 'BEGIN{ if(d<=0){print 0} else {printf "%.3f", (i/ (n+1))*d }}')
|
||||||
|
out="$FRAMES_DIR/frame_$(printf "%03d" "$frame_count").jpg"
|
||||||
|
extract_video_frame_at "$full" "$time" "$out"
|
||||||
|
frame_count=$((frame_count+1))
|
||||||
|
i=$((i+1))
|
||||||
|
if [ "$frame_count" -ge "$MAX_FRAMES" ]; then break 2; fi
|
||||||
|
done
|
||||||
|
else
|
||||||
|
# image
|
||||||
|
out="$FRAMES_DIR/frame_$(printf "%03d" "$frame_count").jpg"
|
||||||
|
extract_image_frame "$full" "$out"
|
||||||
|
frame_count=$((frame_count+1))
|
||||||
|
if [ "$frame_count" -ge "$MAX_FRAMES" ]; then break; fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Gather produced frames
|
||||||
|
mapfile -t FRAMES < <(find "$FRAMES_DIR" -type f -name '*.jpg' | sort)
|
||||||
|
if [ "${#FRAMES[@]}" -eq 0 ]; then
|
||||||
|
echo "No frames produced from media" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build prompt for Gemini
|
||||||
|
if [ -z "$TITLE" ]; then
|
||||||
|
# Default title: directory basename
|
||||||
|
TITLE="$(basename "$IN_DIR")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
PROMPT=$(cat <<EOF
|
||||||
|
You write short descriptions for my motorcycle/travel blog.
|
||||||
|
You should talk like the main character from TV show Shoresy - profane, passionate,
|
||||||
|
and hilariously obsessive titular protagonist of the acclaimed Canadian ice hockey comedy series.
|
||||||
|
Use real quotes from the show as much as possible.
|
||||||
|
|
||||||
|
Title: "$TITLE"
|
||||||
|
Location: "$LOCATION"
|
||||||
|
|
||||||
|
Look at these captured frames and write JSON only.
|
||||||
|
|
||||||
|
Required JSON format:
|
||||||
|
{
|
||||||
|
"description": "2-3 relaxed human sentences",
|
||||||
|
"teaser": "one short teaser sentence",
|
||||||
|
"tags": ["tag1", "tag2", "tag3", "tag4", "tag5"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Style:
|
||||||
|
personal, natural, slightly casual, not corporate, not clickbait.
|
||||||
|
Do not invent facts that are not visible in the images or provided in title/location.
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
PARTS_FILE="$TMPDIR/parts.json"
|
||||||
|
|
||||||
|
# Create parts with the prompt
|
||||||
|
if ! command -v jq >/dev/null 2>&1; then
|
||||||
|
echo "jq is required" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build parts and request JSON using Python to avoid argument-list limits
|
||||||
|
PROMPT_FILE="$TMPDIR/prompt.txt"
|
||||||
|
printf "%s" "$PROMPT" > "$PROMPT_FILE"
|
||||||
|
FRAMES_LIST="$TMPDIR/frames.list"
|
||||||
|
printf "%s\n" "${FRAMES[@]}" > "$FRAMES_LIST"
|
||||||
|
|
||||||
|
REQUEST_FILE="$TMPDIR/request.json"
|
||||||
|
RESPONSE_FILE="$TMPDIR/response.json"
|
||||||
|
|
||||||
|
python3 - "$PROMPT_FILE" "$REQUEST_FILE" "$FRAMES_LIST" <<'PY'
|
||||||
|
import sys, json, base64
|
||||||
|
prompt_file = sys.argv[1]
|
||||||
|
request_file = sys.argv[2]
|
||||||
|
frames_list = sys.argv[3]
|
||||||
|
with open(prompt_file, 'r', encoding='utf-8') as f:
|
||||||
|
prompt = f.read()
|
||||||
|
parts = [{"text": prompt}]
|
||||||
|
with open(frames_list, 'r', encoding='utf-8') as fl:
|
||||||
|
for line in fl:
|
||||||
|
img = line.strip()
|
||||||
|
if not img:
|
||||||
|
continue
|
||||||
|
with open(img, 'rb') as fh:
|
||||||
|
b64 = base64.b64encode(fh.read()).decode('ascii')
|
||||||
|
parts.append({"inline_data": {"mime_type": "image/jpeg", "data": b64}})
|
||||||
|
req = {"contents": [{"parts": parts}], "generationConfig": {"temperature": 0.7, "responseMimeType": "application/json"}}
|
||||||
|
with open(request_file, 'w', encoding='utf-8') as rf:
|
||||||
|
json.dump(req, rf, ensure_ascii=False)
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "Calling Gemini API..."
|
||||||
|
echo "DEBUG_MODEL=$MODEL" >&2
|
||||||
|
|
||||||
|
curl -sS \
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${GEMINI_API_KEY}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-X POST \
|
||||||
|
-d @"$REQUEST_FILE" \
|
||||||
|
> "$RESPONSE_FILE"
|
||||||
|
# debug: save request/response for inspection
|
||||||
|
cp "$REQUEST_FILE" "$IN_DIR/debug_request.json" || true
|
||||||
|
cp "$RESPONSE_FILE" "$IN_DIR/debug_response.json" || true
|
||||||
|
chown www-data:www-data "$IN_DIR/debug_request.json" "$IN_DIR/debug_response.json" 2>/dev/null || true
|
||||||
|
|
||||||
|
TEXT=$(jq -r '.candidates[0].content.parts[0].text // empty' "$RESPONSE_FILE")
|
||||||
|
if [ -z "$TEXT" ]; then
|
||||||
|
echo "Gemini returned no text. Full response:" >&2
|
||||||
|
cat "$RESPONSE_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Save raw Gemini JSON for reference
|
||||||
|
OUT_JSON="$IN_DIR/gemini_generated.json"
|
||||||
|
echo "$TEXT" | jq . > "$OUT_JSON"
|
||||||
|
|
||||||
|
# Extract fields from Gemini JSON
|
||||||
|
DESCRIPTION=$(echo "$TEXT" | jq -r '.description // empty')
|
||||||
|
TEASER=$(echo "$TEXT" | jq -r '.teaser // empty')
|
||||||
|
# tags as comma-separated
|
||||||
|
TAGS=$(echo "$TEXT" | jq -r '.tags // [] | join(", ")')
|
||||||
|
|
||||||
|
# Determine a Date string (Month Year) from first media file mtime
|
||||||
|
firstfile="$(find "$IN_DIR" -maxdepth 1 -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' -o -iname '*.gif' -o -iname '*.mp4' -o -iname '*.mov' -o -iname '*.m4v' -o -iname '*.webm' -o -iname '*.mkv' \) -printf '%p\n' | sort | head -n1)"
|
||||||
|
DATE_STR=""
|
||||||
|
if [ -n "$firstfile" ]; then
|
||||||
|
DATE_STR=$(date -r "$firstfile" +"%B %Y")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update data.txt: only add a Description block if none exists already. Do NOT modify other fields.
|
||||||
|
DATA_FILE="$IN_DIR/data.txt"
|
||||||
|
# If Gemini provided no explicit 'description' field, fall back to the full response text.
|
||||||
|
if [ -z "$DESCRIPTION" ]; then
|
||||||
|
DESCRIPTION="$TEXT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$DATA_FILE" ]; then
|
||||||
|
if grep -qi '^[[:space:]]*description[[:space:]]*:' "$DATA_FILE"; then
|
||||||
|
echo "data.txt already contains a Description; leaving unchanged" >&2
|
||||||
|
else
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "Description: |"
|
||||||
|
printf '%s\n' "$DESCRIPTION" | sed 's/^/ /'
|
||||||
|
} >> "$DATA_FILE"
|
||||||
|
chown www-data:www-data "$DATA_FILE" 2>/dev/null || true
|
||||||
|
chmod 664 "$DATA_FILE" 2>/dev/null || true
|
||||||
|
echo "Appended Description to $DATA_FILE" >&2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
{
|
||||||
|
echo "Description: |"
|
||||||
|
printf '%s\n' "$DESCRIPTION" | sed 's/^/ /'
|
||||||
|
} > "$DATA_FILE"
|
||||||
|
chown www-data:www-data "$DATA_FILE" 2>/dev/null || true
|
||||||
|
chmod 664 "$DATA_FILE" 2>/dev/null || true
|
||||||
|
echo "Created $DATA_FILE with Description" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Save the full Gemini output separately (already done) and exit
|
||||||
|
echo "Gemini JSON saved: $OUT_JSON"
|
||||||
|
|
||||||
|
exit 0
|
||||||
|
|
@ -108,7 +108,7 @@ if [ -z "$TITLE" ]; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
PROMPT=$(cat <<EOF
|
PROMPT=$(cat <<EOF
|
||||||
You write short descriptions for my motorcycle/travel blog.
|
You write descriptions (not les tha 60 or more than 70 words) for my motorcycle/travel blog.
|
||||||
You should talk like the main character from TV show Shoresy - profane, passionate,
|
You should talk like the main character from TV show Shoresy - profane, passionate,
|
||||||
and hilariously obsessive titular protagonist of the acclaimed Canadian ice hockey comedy series.
|
and hilariously obsessive titular protagonist of the acclaimed Canadian ice hockey comedy series.
|
||||||
Use real quotes from the show as much as possible.
|
Use real quotes from the show as much as possible.
|
||||||
|
|
|
||||||
142
lib/send_push.php
Normal file
142
lib/send_push.php
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* send_push.php
|
||||||
|
* Helper function to send "Show enabled" web-push notifications from the webserver.
|
||||||
|
*
|
||||||
|
* Function: try_send_show_notification(string $job, string $jobdir, string $mvlog_root = null, string $logfile = null): bool
|
||||||
|
*
|
||||||
|
* Returns true on successful send and cache update, false otherwise.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function try_send_show_notification($job, $jobdir, $mvlog_root = null, $logfile = null)
|
||||||
|
{
|
||||||
|
if ($mvlog_root === null) $mvlog_root = dirname(__DIR__);
|
||||||
|
if ($logfile === null) $logfile = '/var/log/mvlog_notify.log';
|
||||||
|
|
||||||
|
$subs_file = $mvlog_root . '/cache/push_subscriptions.json';
|
||||||
|
$cache_file = $mvlog_root . '/cache/push_notifications.json';
|
||||||
|
$push_config_candidates = [
|
||||||
|
$mvlog_root . '/push.json',
|
||||||
|
(getenv('HOME') !== false ? getenv('HOME') . '/.config/mvlog/push.json' : null),
|
||||||
|
'/etc/mvlog/push.json',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Don't notify for hidden jobs
|
||||||
|
if (is_file($jobdir . '/.mvlog-hidden')) {
|
||||||
|
error_log("[mvlog] job $job hidden, skipping push\n", 3, $logfile);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gather subscriptions
|
||||||
|
if (!is_file($subs_file)) {
|
||||||
|
error_log("[mvlog] subscriptions file missing, skipping push for $job\n", 3, $logfile);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$subs_raw = @file_get_contents($subs_file);
|
||||||
|
if (!is_string($subs_raw) || trim($subs_raw) === '') {
|
||||||
|
error_log("[mvlog] subscriptions empty, skipping push for $job\n", 3, $logfile);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$subs = json_decode($subs_raw, true);
|
||||||
|
if (!is_array($subs) || count($subs) === 0) {
|
||||||
|
error_log("[mvlog] subscriptions invalid/empty, skipping push for $job\n", 3, $logfile);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read cache and check if already shown
|
||||||
|
$cache = [];
|
||||||
|
if (is_file($cache_file)) {
|
||||||
|
$cache = json_decode((string)@file_get_contents($cache_file), true) ?: [];
|
||||||
|
}
|
||||||
|
$shown = isset($cache['shown']) && is_array($cache['shown']) ? $cache['shown'] : [];
|
||||||
|
if (isset($shown[$job])) {
|
||||||
|
error_log("[mvlog] push already sent for $job, skipping\n", 3, $logfile);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine push config file
|
||||||
|
$push_config = null;
|
||||||
|
foreach ($push_config_candidates as $candidate) {
|
||||||
|
if (!$candidate) continue;
|
||||||
|
if (is_file($candidate) && is_readable($candidate)) { $push_config = $candidate; break; }
|
||||||
|
}
|
||||||
|
if (!$push_config) {
|
||||||
|
error_log("[mvlog] push config not found, skipping push for $job\n", 3, $logfile);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine a title/url for the notification. Prefer existing output filename if present.
|
||||||
|
$title = preg_replace('/_+/', ' ', $job);
|
||||||
|
$title = ucwords($title);
|
||||||
|
$state_file = $jobdir . '/.movmaker-state.json';
|
||||||
|
if (is_file($state_file)) {
|
||||||
|
$state = json_decode(@file_get_contents($state_file), true) ?: [];
|
||||||
|
if (!empty($state['output']) && is_string($state['output'])) {
|
||||||
|
$out = pathinfo($state['output'], PATHINFO_FILENAME);
|
||||||
|
if ($out) {
|
||||||
|
$title = preg_replace('/_+/', ' ', $out);
|
||||||
|
$title = ucwords($title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = json_encode([
|
||||||
|
'title' => 'Show enabled',
|
||||||
|
'body' => $title,
|
||||||
|
'url' => "https://bubulescu.org/mvlog/?job={$job}",
|
||||||
|
'tag' => "mvlog-show-{$job}",
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
// Call node send_push.js with subscriptions on stdin
|
||||||
|
$send_script = escapeshellarg($mvlog_root . '/send_push.js');
|
||||||
|
$push_config_esc = escapeshellarg($push_config);
|
||||||
|
$payload_esc = escapeshellarg($payload);
|
||||||
|
$cmd = "node $send_script $push_config_esc $payload_esc";
|
||||||
|
|
||||||
|
$descriptorspec = [
|
||||||
|
0 => ['pipe', 'r'], // stdin
|
||||||
|
1 => ['pipe', 'w'], // stdout
|
||||||
|
2 => ['pipe', 'w'], // stderr
|
||||||
|
];
|
||||||
|
|
||||||
|
$proc = proc_open($cmd, $descriptorspec, $pipes);
|
||||||
|
if (!is_resource($proc)) {
|
||||||
|
error_log("[mvlog] failed to start node send_push process for $job\n", 3, $logfile);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write subscriptions to stdin
|
||||||
|
fwrite($pipes[0], $subs_raw);
|
||||||
|
fclose($pipes[0]);
|
||||||
|
|
||||||
|
$stdout = stream_get_contents($pipes[1]); fclose($pipes[1]);
|
||||||
|
$stderr = stream_get_contents($pipes[2]); fclose($pipes[2]);
|
||||||
|
|
||||||
|
$rc = proc_close($proc);
|
||||||
|
if ($rc !== 0) {
|
||||||
|
error_log("[mvlog] send_push failed for $job rc=$rc stdout=" . trim($stdout) . " stderr=" . trim($stderr) . "\n", 3, $logfile);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// On success, record in cache['shown']
|
||||||
|
if (!is_array($cache)) $cache = [];
|
||||||
|
if (!isset($cache['shown']) || !is_array($cache['shown'])) $cache['shown'] = [];
|
||||||
|
$cache['shown'][$job] = ['sent_at' => date(DATE_ATOM)];
|
||||||
|
|
||||||
|
// Atomic write: write to tmp then rename
|
||||||
|
$tmp = $cache_file . '.tmp.' . bin2hex(random_bytes(6));
|
||||||
|
$json = json_encode($cache, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
|
||||||
|
if (@file_put_contents($tmp, $json, LOCK_EX) === false) {
|
||||||
|
error_log("[mvlog] failed to write cache temp file for $job\n", 3, $logfile);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
@chmod($tmp, 0664);
|
||||||
|
if (!@rename($tmp, $cache_file)) {
|
||||||
|
@unlink($tmp);
|
||||||
|
error_log("[mvlog] failed to atomically write cache file for $job\n", 3, $logfile);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_log("[mvlog] show-notification sent: $job\n", 3, $logfile);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
169
package-lock.json
generated
Normal file
169
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
{
|
||||||
|
"name": "mvlog",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"web-push": "^3.6.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/agent-base": {
|
||||||
|
"version": "7.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
|
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/asn1.js": {
|
||||||
|
"version": "5.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||||
|
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||||
|
"dependencies": {
|
||||||
|
"bn.js": "^4.0.0",
|
||||||
|
"inherits": "^2.0.1",
|
||||||
|
"minimalistic-assert": "^1.0.0",
|
||||||
|
"safer-buffer": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bn.js": {
|
||||||
|
"version": "4.12.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
|
||||||
|
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="
|
||||||
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
|
||||||
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/http_ece": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/https-proxy-agent": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "^7.1.2",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||||
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minimalistic-assert": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
|
||||||
|
},
|
||||||
|
"node_modules/minimist": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||||
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
||||||
|
},
|
||||||
|
"node_modules/web-push": {
|
||||||
|
"version": "3.6.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||||
|
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||||
|
"dependencies": {
|
||||||
|
"asn1.js": "^5.3.0",
|
||||||
|
"http_ece": "1.2.0",
|
||||||
|
"https-proxy-agent": "^7.0.0",
|
||||||
|
"jws": "^4.0.0",
|
||||||
|
"minimist": "^1.2.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"web-push": "src/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
package.json
Normal file
5
package.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"web-push": "^3.6.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
33
send_push.js
Normal file
33
send_push.js
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
const fs = require('fs');
|
||||||
|
const webpush = require('web-push');
|
||||||
|
|
||||||
|
function readStdin() {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
let data = '';
|
||||||
|
process.stdin.setEncoding('utf8');
|
||||||
|
process.stdin.on('data', chunk => data += chunk);
|
||||||
|
process.stdin.on('end', () => resolve(data));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const configPath = process.argv[2] || `${process.env.HOME}/.config/mvlog/push.json`;
|
||||||
|
const payload = process.argv[3] || '{}';
|
||||||
|
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||||
|
const input = JSON.parse(await readStdin() || '[]');
|
||||||
|
const subs = Array.isArray(input) ? input : [];
|
||||||
|
if (!cfg.public_key || !cfg.private_key || !subs.length) return;
|
||||||
|
webpush.setVapidDetails(cfg.subject || 'mailto:admin@bubulescu.org', cfg.public_key, cfg.private_key);
|
||||||
|
let sent = 0;
|
||||||
|
for (const sub of subs) {
|
||||||
|
try {
|
||||||
|
await webpush.sendNotification(sub, payload);
|
||||||
|
sent++;
|
||||||
|
} catch (err) {
|
||||||
|
const code = err && err.statusCode ? ` status=${err.statusCode}` : '';
|
||||||
|
console.error(`push failed${code}: ${err.message || err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`push sent: ${sent}/${subs.length}`);
|
||||||
|
})().catch(err => { console.error(err.stack || err); process.exit(1); });
|
||||||
Loading…
Add table
Add a link
Reference in a new issue