forked from Rockachopa/Timmy-time-dashboard
240 lines
7.1 KiB
Python
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,
|
|
)
|