print: add CUPS+Avahi virtual printer + spool watcher

Adds Docker assets for a macOS-friendly print-to-PDF pipeline: CUPS (cups-pdf) writes to a shared spool and a watcher uploads PDFs to Omnivore via the existing GraphQL signed-upload flow.
This commit is contained in:
Rohit Amarnath 2026-01-28 11:46:58 -05:00
parent 99c16be31a
commit 277300ef04
7 changed files with 361 additions and 0 deletions

52
packages/print/README.md Normal file
View file

@ -0,0 +1,52 @@
# Omnivore Print-to-PDF (macOS)
This adds a local IPP “virtual printer” (running in Docker) that writes PDFs to a shared spool volume, then uploads each PDF into your Omnivore library as a File/PDF item via the normal GraphQL signed-upload flow.
## Requirements
- Self-hosting stack running via docker compose
- A dedicated watcher env file:
- `self-hosting/docker-compose/print-watcher/.env` (copy from `.env.example`)
- `print-server` runs on its own LAN IP (macvlan) for Bonjour/mDNS. You must set:
- `PRINT_MACVLAN_PARENT` (host interface name, e.g. `eth0`)
- `PRINT_SERVER_IP` (an unused LAN IP, e.g. `10.0.1.250`)
- Optional: `PRINT_MACVLAN_SUBNET`, `PRINT_MACVLAN_GATEWAY`
## Start services
From `self-hosting/docker-compose/` (canonical) or `self-hosting/docker-compose/self-build/`:
```sh
COMPOSE_PROFILES=print docker compose up -d --build
```
This exposes IPP on:
- `PRINT_SERVER_IP:631` (CUPS on the print server containers LAN IP)
- Bonjour/mDNS advertisement (should appear automatically in macOS “Add Printer…”)
## Add the printer in macOS
Option A (preferred): use Bonjour discovery
1. System Settings → Printers & Scanners → Add Printer…
2. Default tab → select `OmnivorePDF` (or similar) when it appears.
Option B: manual IPP URL
1. System Settings → Printers & Scanners → Add Printer…
2. IP tab
3. Address: `PRINT_SERVER_IP`
4. Protocol: `Internet Printing Protocol - IPP`
5. Queue: `printers/OmnivorePDF`
When you print to this printer, PDFs should appear in Omnivore automatically.
## Troubleshooting
- Verify the services:
- `docker compose logs -f print-server`
- `docker compose logs -f print-watcher`
- If uploads fail, confirm:
- `OMNIVORE_API_KEY` is present in your compose `.env`.
- `OMNIVORE_GRAPHQL_ENDPOINT` is reachable from the watcher container (defaults to `http://api:8080/api/graphql`).

View file

@ -0,0 +1,24 @@
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
cups \
cups-pdf \
avahi-daemon \
avahi-utils \
libnss-mdns \
dbus \
ghostscript \
procps \
&& rm -rf /var/lib/apt/lists/*
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENV PRINT_SPOOL_DIR=/spool/incoming
ENV PRINTER_NAME=OmnivorePDF
VOLUME ["/spool"]
EXPOSE 631/tcp 5353/udp
CMD ["/entrypoint.sh"]

View file

@ -0,0 +1,103 @@
#!/usr/bin/env bash
set -euo pipefail
SPOOL_DIR="${PRINT_SPOOL_DIR:-/spool/incoming}"
PRINTER_NAME="${PRINTER_NAME:-OmnivorePDF}"
mkdir -p "$SPOOL_DIR"
# Configure cups-pdf output directory.
if [[ -f /etc/cups/cups-pdf.conf ]]; then
# Default config ships with "#Out ...". Un-comment (or replace) it so output goes
# into the shared spool volume.
sed -i -E "s|^[[:space:]]*#?[[:space:]]*Out[[:space:]]+.*$|Out ${SPOOL_DIR}|" /etc/cups/cups-pdf.conf || true
fi
# Configure CUPS to listen on LAN + allow printing + advertise via DNS-SD.
if [[ -f /etc/cups/cupsd.conf ]]; then
tmp="$(mktemp)"
awk '
BEGIN { in_root=0; found_printers=0 }
$0 == "Listen localhost:631" { print "Port 631"; next }
$0 == "<Location />" {
print "<Location />"
print " Order allow,deny"
print " Allow all"
print "</Location>"
in_root=1
next
}
in_root {
if ($0 == "</Location>") in_root=0
next
}
$0 == "<Location /printers>" { found_printers=1 }
{ print }
END {
if (!found_printers) {
print ""
print "<Location /printers>"
print " Order allow,deny"
print " Allow all"
print "</Location>"
}
}
' /etc/cups/cupsd.conf > "$tmp"
mv "$tmp" /etc/cups/cupsd.conf
if ! grep -q '^ServerAlias \\*$' /etc/cups/cupsd.conf; then
echo 'ServerAlias *' >> /etc/cups/cupsd.conf
fi
if ! grep -q '^DefaultShared Yes$' /etc/cups/cupsd.conf; then
echo 'DefaultShared Yes' >> /etc/cups/cupsd.conf
fi
if ! grep -q '^Browsing On$' /etc/cups/cupsd.conf; then
echo 'Browsing On' >> /etc/cups/cupsd.conf
fi
if ! grep -q '^BrowseLocalProtocols dnssd$' /etc/cups/cupsd.conf; then
echo 'BrowseLocalProtocols dnssd' >> /etc/cups/cupsd.conf
fi
fi
# Avahi requires a system dbus.
mkdir -p /run/dbus
dbus-daemon --system --fork
# Ensure avahi runs without chroot (common in containers).
mkdir -p /run/avahi-daemon
avahi-daemon --no-chroot --daemonize || true
# Start CUPS in the background to allow printer setup.
cupsd -f &
CUPSD_PID=$!
# Wait briefly for cupsd socket.
for _ in $(seq 1 20); do
if lpstat -r >/dev/null 2>&1; then
break
fi
sleep 0.2
done
# Create the PDF printer if missing.
if ! lpstat -p "${PRINTER_NAME}" >/dev/null 2>&1; then
PPD="$(find /usr/share -maxdepth 6 -type f -iname '*cups-pdf*.ppd' -print -quit 2>/dev/null || true)"
if [[ -z "$PPD" ]]; then
PPD="$(find /usr/share -maxdepth 6 -type f -iname '*CUPS-PDF*.ppd' -print -quit 2>/dev/null || true)"
fi
if [[ -n "$PPD" && -f "$PPD" ]]; then
lpadmin -p "${PRINTER_NAME}" -E -v "cups-pdf:/" -P "$PPD" -o printer-is-shared=true || true
else
# If no PPD is found, we can still create the queue but driver selection may vary by client.
lpadmin -p "${PRINTER_NAME}" -E -v "cups-pdf:/" -m raw -o printer-is-shared=true || true
fi
cupsenable "${PRINTER_NAME}" || true
cupsaccept "${PRINTER_NAME}" || true
fi
echo "CUPS ready. Printer: ${PRINTER_NAME}. Spool: ${SPOOL_DIR}"
# Keep the container running.
wait "${CUPSD_PID}"

View file

@ -0,0 +1,6 @@
{
"name": "@omnivore/print",
"private": true,
"version": "0.0.0",
"description": "Docker-based print-to-Omnivore pipeline assets (IPP server + watcher)."
}

View file

@ -0,0 +1,17 @@
FROM alpine:3.19
RUN apk add --no-cache bash curl jq inotify-tools coreutils ca-certificates
COPY omnivore-print-upload.sh /usr/local/bin/omnivore-print-upload
COPY omnivore-print-watch.sh /usr/local/bin/omnivore-print-watch
RUN chmod +x /usr/local/bin/omnivore-print-upload /usr/local/bin/omnivore-print-watch
ENV OMNIVORE_GRAPHQL_ENDPOINT=http://api:8080/api/graphql
ENV PRINT_SPOOL_DIR=/spool/incoming
ENV PRINT_PROCESSING_DIR=/spool/processing
ENV PRINT_DONE_DIR=/spool/done
ENV PRINT_FAILED_DIR=/spool/failed
CMD ["/usr/local/bin/omnivore-print-watch"]

View file

@ -0,0 +1,100 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "usage: omnivore-print-upload <pdf-path>" >&2
exit 2
fi
PDF_PATH="$1"
if [[ ! -f "$PDF_PATH" ]]; then
echo "file not found: $PDF_PATH" >&2
exit 2
fi
if [[ "${PDF_PATH,,}" != *.pdf ]]; then
echo "not a pdf: $PDF_PATH" >&2
exit 2
fi
if [[ -z "${OMNIVORE_API_KEY:-}" ]]; then
echo "Missing OMNIVORE_API_KEY" >&2
exit 2
fi
GRAPHQL_ENDPOINT="${OMNIVORE_GRAPHQL_ENDPOINT:-http://api:8080/api/graphql}"
base="$(basename "$PDF_PATH")"
encoded_base="$(printf '%s' "$base" | jq -sRr @uri)"
file_url="file:///omnivore-print/${encoded_base}"
UPLOAD_MUTATION='mutation UploadFileRequest($input: UploadFileRequestInput!) { uploadFileRequest(input:$input) { __typename ... on UploadFileRequestSuccess { id uploadSignedUrl createdPageId } ... on UploadFileRequestError { errorCodes } } }'
SAVE_MUTATION='mutation SaveFile($input: SaveFileInput!) { saveFile(input:$input) { __typename ... on SaveSuccess { url clientRequestId } ... on SaveError { errorCodes message } } }'
graphql() {
local payload="$1"
curl -sS \
-H "Content-Type: application/json" \
-H "Omnivore-Authorization: ${OMNIVORE_API_KEY}" \
-H "User-Agent: omnivore-print-watcher/1.0" \
--data "$payload" \
"$GRAPHQL_ENDPOINT"
}
upload_payload="$(
jq -nc \
--arg query "$UPLOAD_MUTATION" \
--arg url "$file_url" \
--arg contentType "application/pdf" \
'{query:$query, variables:{input:{url:$url, contentType:$contentType, createPageEntry:true}}}'
)"
upload_resp="$(graphql "$upload_payload")"
if [[ "$(jq -r '.errors | length // 0' <<<"$upload_resp")" != "0" ]]; then
echo "uploadFileRequest graphql errors: $(jq -c '.errors' <<<"$upload_resp")" >&2
exit 1
fi
upload_typename="$(jq -r '.data.uploadFileRequest.__typename // empty' <<<"$upload_resp")"
if [[ "$upload_typename" != "UploadFileRequestSuccess" ]]; then
echo "uploadFileRequest failed: $(jq -c '.data.uploadFileRequest' <<<"$upload_resp")" >&2
exit 1
fi
upload_file_id="$(jq -r '.data.uploadFileRequest.id' <<<"$upload_resp")"
upload_signed_url="$(jq -r '.data.uploadFileRequest.uploadSignedUrl' <<<"$upload_resp")"
created_page_id="$(jq -r '.data.uploadFileRequest.createdPageId' <<<"$upload_resp")"
curl -sS --fail \
-X PUT \
-H "Content-Type: application/pdf" \
--data-binary @"$PDF_PATH" \
"$upload_signed_url" >/dev/null
save_payload="$(
jq -nc \
--arg query "$SAVE_MUTATION" \
--arg url "$file_url" \
--arg source "print" \
--arg clientRequestId "$created_page_id" \
--arg uploadFileId "$upload_file_id" \
'{query:$query, variables:{input:{url:$url, source:$source, clientRequestId:$clientRequestId, uploadFileId:$uploadFileId}}}'
)"
save_resp="$(graphql "$save_payload")"
if [[ "$(jq -r '.errors | length // 0' <<<"$save_resp")" != "0" ]]; then
echo "saveFile graphql errors: $(jq -c '.errors' <<<"$save_resp")" >&2
exit 1
fi
save_typename="$(jq -r '.data.saveFile.__typename // empty' <<<"$save_resp")"
if [[ "$save_typename" != "SaveSuccess" ]]; then
echo "saveFile failed: $(jq -c '.data.saveFile' <<<"$save_resp")" >&2
exit 1
fi
echo "uploaded ${base} -> pageId=${created_page_id}"

View file

@ -0,0 +1,59 @@
#!/usr/bin/env bash
set -euo pipefail
INCOMING_DIR="${PRINT_SPOOL_DIR:-/spool/incoming}"
PROCESSING_DIR="${PRINT_PROCESSING_DIR:-/spool/processing}"
DONE_DIR="${PRINT_DONE_DIR:-/spool/done}"
FAILED_DIR="${PRINT_FAILED_DIR:-/spool/failed}"
mkdir -p "$INCOMING_DIR" "$PROCESSING_DIR" "$DONE_DIR" "$FAILED_DIR"
unique_dest() {
local dir="$1"
local base="$2"
local dest="${dir}/${base}"
if [[ ! -e "$dest" ]]; then
echo "$dest"
return
fi
local ts
ts="$(date -u +%Y%m%dT%H%M%SZ)"
echo "${dir}/${ts}.$$-${base}"
}
process_one() {
local src="$1"
if [[ ! -f "$src" ]]; then
return 0
fi
if [[ "${src,,}" != *.pdf ]]; then
return 0
fi
local base processing done failed
base="$(basename "$src")"
processing="$(unique_dest "$PROCESSING_DIR" "$base")"
mv "$src" "$processing"
if /usr/local/bin/omnivore-print-upload "$processing"; then
done="$(unique_dest "$DONE_DIR" "$(basename "$processing")")"
mv "$processing" "$done"
else
failed="$(unique_dest "$FAILED_DIR" "$(basename "$processing")")"
mv "$processing" "$failed"
fi
}
while IFS= read -r -d '' f; do
process_one "$f"
done < <(find "$INCOMING_DIR" -type f \( -iname '*.pdf' -o -iname '*.PDF' \) -print0 2>/dev/null || true)
echo "watching $INCOMING_DIR for printed PDFs..."
inotifywait -m -r \
-e close_write \
-e moved_to \
--format '%w%f' \
"$INCOMING_DIR" | while read -r path; do
process_one "$path"
done