Add remaining relevant files (auto)

This commit is contained in:
hbrain 2026-05-29 13:29:12 +02:00
parent c2d64cb00a
commit 6b00b0d7fe
7 changed files with 649 additions and 1 deletions

57
api/enable_show.php Normal file
View 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
View 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

View file

@ -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
View 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
View 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
View file

@ -0,0 +1,5 @@
{
"dependencies": {
"web-push": "^3.6.7"
}
}

33
send_push.js Normal file
View 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); });