Skip to content

feat: push notifications via the push gateway (#4)#80

Merged
windoze95 merged 1 commit into
mainfrom
feature/backend-push
Jun 28, 2026
Merged

feat: push notifications via the push gateway (#4)#80
windoze95 merged 1 commit into
mainfrom
feature/backend-push

Conversation

@windoze95

Copy link
Copy Markdown
Owner

New-episode push notifications (roadmap LATER tier, #4), integrated with the public push gateway (push.julian.codes) the same way Cantinarr does. Additive — no migration; disabled unless a gateway URL is configured (it defaults to the public relay).

Config (app/config.py, .env.example)

NULLFEED_PUSH_GATEWAY_URL (default https://push.julian.codes), NULLFEED_PUSH_API_KEY (default ""), NULLFEED_PUSH_ENROLL_TOKEN (default ""). Blank URL ⇒ push fully disabled (every path no-ops).

Gateway client (app/services/push_gateway.py)

  • Auto-enroll on first use (POST /v1/enroll), persisting the issued pgk_ key as a 0600 file under config_path via the same atomic hard-link claim as the ticket secret (survives restart, shared across workers). Explicit NULLFEED_PUSH_API_KEY wins and isn't persisted.
  • register_device / unregister_device (POST/DELETE /v1/devices, topic codes.julian.nullfeed), send_to_users (POST /v1/notifications, to.user_ids only — tenant keys can't target raw tokens). Sync core (for the Celery poller) + async wrappers via asyncio.to_thread (for endpoints). Best-effort throughout: disabled/unreachable/error is logged and swallowed, never raised into the poller or an endpoint.

Endpoints (app/api/push.py, X-User-Token auth)

POST /api/push/register {device_token, device_id?, platform?="ios"} → forwards to the gateway with user.id; DELETE /api/push/register {device_id|device_token}. Push disabled ⇒ 200 {"enabled": false}.

Send on new episode

channel_poller._emit_new_episode_events (sync Celery, after the back-catalog gate + WS publish) sends per subscriber×new video: to.user_ids=[user_id], alert title=channel/body=video title, data {"type":"new_episode","video_id"} for deep-linking, priority high. A push error never breaks polling.

Verification (local)

  • pytest -q239 passed (+15, httpx mocked: auto-enroll persists+reuses, register forwards user.id+token+topic, new_episode→send with right to.user_ids+data.video_id, disabled no-ops, gateway error doesn't break the poll).
  • ruff + mypy clean · no migration.

Clients next: APNs registration → POST /api/push/register; tap on data.video_id/player/:id. Needs you (I'll handle with your access): confirm the gateway's enrollment mode (auto-enroll assumes open; else an enroll token / explicit key) and the Apple Push capability on codes.julian.nullfeed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RXMKM1rDWn8wNh93MMUtxY

…33)

NullFeed had live WebSocket events for an open app but no way to reach a
backgrounded/closed one. Integrate the shared push gateway (push.julian.codes)
so new-episode notifications land as real iOS pushes, modeled on Cantinarr's
gateway client. No Apple push key of our own is required.

Config (app/config.py): NULLFEED_PUSH_GATEWAY_URL (default
https://push.julian.codes), NULLFEED_PUSH_API_KEY, NULLFEED_PUSH_ENROLL_TOKEN.
A blank gateway URL disables push entirely (every path no-ops). Documented in
.env.example.

Gateway client (app/services/push_gateway.py): a sync httpx core (enroll,
register_device, unregister_device, send_to_users) with async wrappers
(asyncio.to_thread) for the FastAPI endpoints. Topic is always
codes.julian.nullfeed; tenant keys target user_ids (the gateway rejects raw
tokens). Zero-config auto-enrollment on first use: with no explicit key set we
self-enroll and persist the issued pgk_ key durably under config_path as a 0600
file, claimed with the same atomic hard-link dance as app/utils/tickets.py, so
it survives restarts and is shared by every worker. Everything is best-effort:
a disabled/unreachable gateway or a per-call error is logged and swallowed,
never raised into the poller or an endpoint.

Endpoints (app/api/push.py, mounted in app/main.py), session-authenticated:
- POST /api/push/register {device_token, device_id?, platform?=ios} -> forwards
  to gateway POST /v1/devices with the caller's user id + topic.
- DELETE /api/push/register {device_id | device_token, platform?} -> forwards a
  gateway DELETE /v1/devices.
Both return {"enabled": false} (200) when push is disabled rather than erroring.

Send-on-new-episode: channel_poller._emit_new_episode_events now sends a push
(best-effort, sync) alongside the existing WS publish, per (subscriber, new
video): to.user_ids=[user_id], notification {title: channel name, body: video
title}, data {type:"new_episode", video_id} for client deep-linking. A push
failure never breaks polling.

Persistence is a file (no DB table / migration). Tests (mock httpx, no network):
auto-enroll persists + reuses the key (and sends X-Enroll-Token when gated),
register forwards user.id + token + topic, a new episode triggers a per-
subscriber send with the right to.user_ids + data.video_id, push DISABLED no-ops
the endpoints and the poller (no call-out), and a gateway error during a send
does not break the poll. Suite: 239 passed (15 new). ruff check + format clean;
mypy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMKM1rDWn8wNh93MMUtxY
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@windoze95 windoze95 merged commit 4e20c2c into main Jun 28, 2026
5 checks passed
@windoze95 windoze95 deleted the feature/backend-push branch June 28, 2026 08:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant