1
0
This repository has been archived on 2026-03-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Timmy-time-dashboard/src/content/publishing/nostr.py

240 lines
7.1 KiB
Python

"""Nostr publishing via Blossom (NIP-B7) file upload + NIP-94 metadata event.
Blossom is a content-addressed blob storage protocol for Nostr. This module:
1. Uploads the video file to a Blossom server (NIP-B7 PUT /upload).
2. Publishes a NIP-94 file-metadata event referencing the Blossom URL.
Both operations are optional/degradable:
- If no Blossom server is configured, the upload step is skipped and a
warning is logged.
- If ``nostr-tools`` (or a compatible library) is not available, the event
publication step is skipped.
References
----------
- NIP-B7 : https://github.com/hzrd149/blossom
- NIP-94 : https://github.com/nostr-protocol/nips/blob/master/94.md
Usage
-----
from content.publishing.nostr import publish_episode
result = await publish_episode(
video_path="/tmp/episodes/ep001.mp4",
title="Top Highlights — March 2026",
description="Today's best moments.",
tags=["highlights", "gaming"],
)
"""
from __future__ import annotations
import asyncio
import hashlib
import logging
from dataclasses import dataclass
from pathlib import Path
import httpx
from config import settings
logger = logging.getLogger(__name__)
@dataclass
class NostrPublishResult:
"""Result of a Nostr/Blossom publish attempt."""
success: bool
blossom_url: str | None = None
event_id: str | None = None
error: str | None = None
def _sha256_file(path: str) -> str:
"""Return the lowercase hex SHA-256 digest of a file."""
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(65536), b""):
h.update(chunk)
return h.hexdigest()
async def _blossom_upload(video_path: str) -> tuple[bool, str, str]:
"""Upload a video to the configured Blossom server.
Returns
-------
(success, url_or_error, sha256)
"""
server = settings.content_blossom_server.rstrip("/")
if not server:
return False, "CONTENT_BLOSSOM_SERVER not configured", ""
sha256 = await asyncio.to_thread(_sha256_file, video_path)
file_size = Path(video_path).stat().st_size
pubkey = settings.content_nostr_pubkey
headers: dict[str, str] = {
"Content-Type": "video/mp4",
"X-SHA-256": sha256,
"X-Content-Length": str(file_size),
}
if pubkey:
headers["X-Nostr-Pubkey"] = pubkey
try:
async with httpx.AsyncClient(timeout=600) as client:
with open(video_path, "rb") as fh:
resp = await client.put(
f"{server}/upload",
content=fh.read(),
headers=headers,
)
if resp.status_code in (200, 201):
data = resp.json()
url = data.get("url") or f"{server}/{sha256}"
return True, url, sha256
return False, f"Blossom upload failed: HTTP {resp.status_code} {resp.text[:200]}", sha256
except Exception as exc:
logger.warning("Blossom upload error: %s", exc)
return False, str(exc), sha256
async def _publish_nip94_event(
blossom_url: str,
sha256: str,
title: str,
description: str,
file_size: int,
tags: list[str],
) -> tuple[bool, str]:
"""Build and publish a NIP-94 file-metadata Nostr event.
Returns (success, event_id_or_error).
"""
relay_url = settings.content_nostr_relay
privkey_hex = settings.content_nostr_privkey
if not relay_url or not privkey_hex:
return (
False,
"CONTENT_NOSTR_RELAY and CONTENT_NOSTR_PRIVKEY must be configured",
)
try:
# Build NIP-94 event manually to avoid heavy nostr-tools dependency
import json
import time
event_tags = [
["url", blossom_url],
["x", sha256],
["m", "video/mp4"],
["size", str(file_size)],
["title", title],
] + [["t", t] for t in tags]
event_content = description
# Minimal NIP-01 event construction
pubkey = settings.content_nostr_pubkey or ""
created_at = int(time.time())
kind = 1063 # NIP-94 file metadata
serialized = json.dumps(
[0, pubkey, created_at, kind, event_tags, event_content],
separators=(",", ":"),
ensure_ascii=False,
)
event_id = hashlib.sha256(serialized.encode()).hexdigest()
# Sign event (schnorr via secp256k1 not in stdlib; sig left empty for now)
sig = ""
event = {
"id": event_id,
"pubkey": pubkey,
"created_at": created_at,
"kind": kind,
"tags": event_tags,
"content": event_content,
"sig": sig,
}
async with httpx.AsyncClient(timeout=30) as client:
# Send event to relay via NIP-01 websocket-like REST endpoint
# (some relays accept JSON POST; for full WS support integrate nostr-tools)
resp = await client.post(
relay_url.replace("wss://", "https://").replace("ws://", "http://"),
json=["EVENT", event],
headers={"Content-Type": "application/json"},
)
if resp.status_code in (200, 201):
return True, event_id
return False, f"Relay rejected event: HTTP {resp.status_code}"
except Exception as exc:
logger.warning("NIP-94 event publication failed: %s", exc)
return False, str(exc)
async def publish_episode(
video_path: str,
title: str,
description: str = "",
tags: list[str] | None = None,
) -> NostrPublishResult:
"""Upload video to Blossom and publish NIP-94 metadata event.
Parameters
----------
video_path:
Local path to the episode MP4 file.
title:
Episode title (used in the NIP-94 event).
description:
Episode description.
tags:
Hashtag list (without "#") for discoverability.
Returns
-------
NostrPublishResult
Always returns a result; never raises.
"""
if not Path(video_path).exists():
return NostrPublishResult(success=False, error=f"video file not found: {video_path!r}")
file_size = Path(video_path).stat().st_size
_tags = tags or []
# Step 1: Upload to Blossom
upload_ok, url_or_err, sha256 = await _blossom_upload(video_path)
if not upload_ok:
logger.warning("Blossom upload failed (non-fatal): %s", url_or_err)
return NostrPublishResult(success=False, error=url_or_err)
blossom_url = url_or_err
logger.info("Blossom upload successful: %s", blossom_url)
# Step 2: Publish NIP-94 event
event_ok, event_id_or_err = await _publish_nip94_event(
blossom_url, sha256, title, description, file_size, _tags
)
if not event_ok:
logger.warning("NIP-94 event failed (non-fatal): %s", event_id_or_err)
# Still return partial success — file is uploaded to Blossom
return NostrPublishResult(
success=True,
blossom_url=blossom_url,
error=f"NIP-94 event failed: {event_id_or_err}",
)
return NostrPublishResult(
success=True,
blossom_url=blossom_url,
event_id=event_id_or_err,
)