Nostr stream provider init

This commit is contained in:
2023-07-03 16:55:43 +01:00
parent cbc49a0def
commit 7cc613646c
15 changed files with 566 additions and 198 deletions

View File

@ -1,3 +1,4 @@
.new-stream { .new-stream {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -9,11 +10,6 @@
margin: 0; margin: 0;
} }
.new-stream div.paper {
background: #262626;
height: 32px;
}
.new-stream p { .new-stream p {
margin: 0 0 8px 0; margin: 0 0 8px 0;
} }
@ -23,14 +19,15 @@
margin: 8px 0 0 0; margin: 8px 0 0 0;
} }
.new-stream .btn { .new-stream .btn.wide {
padding: 12px 16px; padding: 12px 16px;
border-radius: 16px; border-radius: 16px;
width: 100%; width: 100%;
} }
.new-stream .btn>span { .new-stream div.paper {
justify-content: center; background: #262626;
height: 32px;
} }
.new-stream .btn:disabled { .new-stream .btn:disabled {

View File

@ -1,196 +1,118 @@
import "./new-stream.css"; import "./new-stream.css";
import * as Dialog from "@radix-ui/react-dialog"; import * as Dialog from "@radix-ui/react-dialog";
import { useEffect, useState, useCallback } from "react";
import { EventPublisher, NostrEvent } from "@snort/system";
import { unixNow } from "@snort/shared";
import AsyncButton from "./async-button";
import { StreamState, System } from "index";
import { Icon } from "element/icon"; import { Icon } from "element/icon";
import { findTag } from "utils"; import { useStreamProvider } from "hooks/stream-provider";
import { StreamProvider, StreamProviderInfo, StreamProviders } from "providers";
import { useEffect, useState } from "react";
import { StreamEditor, StreamEditorProps } from "./stream-editor";
import { useNavigate } from "react-router-dom";
import { eventLink } from "utils";
export function NewStream({ function NewStream({ ev, onFinish }: StreamEditorProps) {
ev, const providers = useStreamProvider();
onFinish, const [currentProvider, setCurrentProvider] = useState<StreamProvider>();
}: { const [info, setInfo] = useState<StreamProviderInfo>();
ev?: NostrEvent; const navigate = useNavigate();
onFinish?: (ev: NostrEvent) => void;
}) {
const [title, setTitle] = useState(findTag(ev, "title") ?? "");
const [summary, setSummary] = useState(findTag(ev, "summary") ?? "");
const [image, setImage] = useState(findTag(ev, "image") ?? "");
const [stream, setStream] = useState(findTag(ev, "streaming") ?? "");
const [status, setStatus] = useState(
findTag(ev, "status") ?? StreamState.Live
);
const [start, setStart] = useState(findTag(ev, "starts"));
const [isValid, setIsValid] = useState(false);
const validate = useCallback(() => { async function loadInfo(p: StreamProvider) {
if (title.length < 2) { const inf = await p.info();
return false; setInfo(inf);
} }
if (stream.length < 5 || !stream.match(/^https?:\/\/.*\.m3u8?$/i)) {
return false;
}
if (image.length > 0 && !image.match(/^https?:\/\//i)) {
return false;
}
return true;
}, [title, image, stream]);
useEffect(() => { useEffect(() => {
setIsValid(validate()); if (!currentProvider) {
}, [validate, title, summary, image, stream]); setCurrentProvider(providers.at(0));
}
if (currentProvider) {
loadInfo(currentProvider).catch(console.error);
}
}, [providers, currentProvider]);
async function publishStream() { function nostrTypeDialog(p: StreamProviderInfo) {
const pub = await EventPublisher.nip7(); return <>
if (pub) {
const evNew = await pub.generic((eb) => {
const now = unixNow();
const dTag = findTag(ev, "d") ?? now.toString();
const starts = start ?? now.toString();
const ends = findTag(ev, "ends") ?? now.toString();
eb.kind(30_311)
.tag(["d", dTag])
.tag(["title", title])
.tag(["summary", summary])
.tag(["image", image])
.tag(["streaming", stream])
.tag(["status", status])
.tag(["starts", starts]);
if (status === StreamState.Ended) {
eb.tag(["ends", ends]);
}
return eb;
});
console.debug(evNew);
System.BroadcastEvent(evNew);
onFinish && onFinish(evNew);
}
}
function toDateTimeString(n: number) {
return new Date(n * 1000).toISOString().substring(0, -1);
}
function fromDateTimeString(s: string) {
return Math.floor(new Date(s).getTime() / 1000);
}
return (
<div className="new-stream">
<h3>{ev ? "Edit Stream" : "New Stream"}</h3>
<div>
<p>Title</p>
<div className="paper">
<input
type="text"
placeholder="What are we steaming today?"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
</div>
<div>
<p>Summary</p>
<div className="paper">
<input
type="text"
placeholder="A short description of the content"
value={summary}
onChange={(e) => setSummary(e.target.value)}
/>
</div>
</div>
<div>
<p>Cover image</p>
<div className="paper">
<input
type="text"
placeholder="https://"
value={image}
onChange={(e) => setImage(e.target.value)}
/>
</div>
</div>
<div> <div>
<p>Stream Url</p> <p>Stream Url</p>
<div className="paper"> <div className="paper">
<input <input type="text" value={p.ingressUrl} disabled />
type="text"
placeholder="https://"
value={stream}
onChange={(e) => setStream(e.target.value)}
/>
</div> </div>
<small>Stream type should be HLS</small>
</div> </div>
<div> <div>
<p>Status</p> <p>Stream Key</p>
<div className="paper">
<input type="password" value={p.ingressKey} disabled />
</div>
</div>
<div>
<p>Balance</p>
<div className="flex g12"> <div className="flex g12">
{[StreamState.Live, StreamState.Planned, StreamState.Ended].map( <div className="paper f-grow">
(v) => ( {p.balance?.toLocaleString()} sats
<span
className={`pill${status === v ? " active" : ""}`}
onClick={() => setStatus(v)}
>
{v}
</span>
)
)}
</div> </div>
<button className="btn btn-primary">
Topup
</button>
</div> </div>
{status === StreamState.Planned && (
<div> </div>
<p>Start Time</p> </>
<div className="input">
<input
type="datetime-local"
value={toDateTimeString(Number(start ?? "0"))}
onChange={(e) =>
setStart(fromDateTimeString(e.target.value).toString())
} }
/>
function providerDialog(p: StreamProviderInfo) {
switch (p.type) {
case StreamProviders.Manual: {
return <StreamEditor onFinish={ex => {
currentProvider?.updateStreamInfo(ex);
if (!ev) {
navigate(eventLink(ex));
} else {
onFinish?.(ev);
}
}} ev={ev} />
}
case StreamProviders.NostrType: {
return <>
{nostrTypeDialog(p)}
<StreamEditor onFinish={(ex) => {
// patch to api
currentProvider?.updateStreamInfo(ex);
onFinish?.(ex);
}} ev={ev ?? p.publishedEvent} options={{
canSetStream: false,
canSetStatus: false
}} />
</>
}
case StreamProviders.Owncast: {
return
}
}
}
return <>
<p>Stream Providers</p>
<div className="flex g12">
{providers.map(v => <span className={`pill${v === currentProvider ? " active" : ""}`} onClick={() => setCurrentProvider(v)}>{v.name}</span>)}
</div> </div>
</div> {info && providerDialog(info)}
)} </>
<div>
<AsyncButton
type="button"
className="btn btn-primary"
disabled={!isValid}
onClick={publishStream}
>
{ev ? "Save" : "Start Stream"}
</AsyncButton>
</div>
</div>
);
} }
interface NewStreamDialogProps { interface NewStreamDialogProps {
text?: string; text?: string;
btnClassName?: string; btnClassName?: string;
ev?: NostrEvent;
onFinish?: (e: NostrEvent) => void;
} }
export function NewStreamDialog({ export function NewStreamDialog(props: NewStreamDialogProps & StreamEditorProps) {
text, const [open, setOpen] = useState(false);
ev,
onFinish,
btnClassName = "btn",
}: NewStreamDialogProps) {
return ( return (
<Dialog.Root> <Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild> <Dialog.Trigger asChild>
<button type="button" className={btnClassName}> <button type="button" className={props.btnClassName}>
{text && text} {props.text && props.text}
{!text && ( {!props.text && (
<> <>
<span className="hide-on-mobile">New Stream</span> <span className="hide-on-mobile">Stream</span>
<Icon name="signal" /> <Icon name="signal" />
</> </>
)} )}
@ -199,7 +121,9 @@ export function NewStreamDialog({
<Dialog.Portal> <Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" /> <Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="dialog-content"> <Dialog.Content className="dialog-content">
<NewStream ev={ev} onFinish={onFinish} /> <div className="new-stream">
<NewStream {...props} onFinish={() => setOpen(false)} />
</div>
</Dialog.Content> </Dialog.Content>
</Dialog.Portal> </Dialog.Portal>
</Dialog.Root> </Dialog.Root>

View File

@ -0,0 +1,166 @@
import { useEffect, useState, useCallback } from "react";
import { EventPublisher, NostrEvent } from "@snort/system";
import { unixNow } from "@snort/shared";
import AsyncButton from "./async-button";
import { StreamState, System } from "index";
import { findTag } from "utils";
export interface StreamEditorProps {
ev?: NostrEvent;
onFinish?: (ev: NostrEvent) => void;
options?: {
canSetTitle?: boolean
canSetSummary?: boolean
canSetImage?: boolean
canSetStatus?: boolean
canSetStream?: boolean
}
}
export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
const [title, setTitle] = useState(findTag(ev, "title") ?? "");
const [summary, setSummary] = useState(findTag(ev, "summary") ?? "");
const [image, setImage] = useState(findTag(ev, "image") ?? "");
const [stream, setStream] = useState(findTag(ev, "streaming") ?? "");
const [status, setStatus] = useState(
findTag(ev, "status") ?? StreamState.Live
);
const [start, setStart] = useState(findTag(ev, "starts"));
const [isValid, setIsValid] = useState(false);
const validate = useCallback(() => {
if (title.length < 2) {
return false;
}
if (stream.length < 5 || !stream.match(/^https?:\/\/.*\.m3u8?$/i)) {
return false;
}
if (image.length > 0 && !image.match(/^https?:\/\//i)) {
return false;
}
return true;
}, [title, image, stream]);
useEffect(() => {
setIsValid(ev !== undefined || validate());
}, [validate, title, summary, image, stream]);
async function publishStream() {
const pub = await EventPublisher.nip7();
if (pub) {
const evNew = await pub.generic((eb) => {
const now = unixNow();
const dTag = findTag(ev, "d") ?? now.toString();
const starts = start ?? now.toString();
const ends = findTag(ev, "ends") ?? now.toString();
eb.kind(30311)
.tag(["d", dTag])
.tag(["title", title])
.tag(["summary", summary])
.tag(["image", image])
.tag(["streaming", stream])
.tag(["status", status])
.tag(["starts", starts]);
if (status === StreamState.Ended) {
eb.tag(["ends", ends]);
}
return eb;
});
console.debug(evNew);
onFinish && onFinish(evNew);
}
}
function toDateTimeString(n: number) {
return new Date(n * 1000).toISOString().substring(0, -1);
}
function fromDateTimeString(s: string) {
return Math.floor(new Date(s).getTime() / 1000);
}
return (
<>
<h3>{ev ? "Edit Stream" : "New Stream"}</h3>
{(options?.canSetTitle === undefined || options.canSetTitle) && <div>
<p>Title</p>
<div className="paper">
<input
type="text"
placeholder="What are we steaming today?"
value={title}
onChange={(e) => setTitle(e.target.value)} />
</div>
</div>}
{(options?.canSetSummary === undefined || options.canSetSummary) && <div>
<p>Summary</p>
<div className="paper">
<input
type="text"
placeholder="A short description of the content"
value={summary}
onChange={(e) => setSummary(e.target.value)} />
</div>
</div>}
{(options?.canSetImage === undefined || options.canSetImage) && <div>
<p>Cover image</p>
<div className="paper">
<input
type="text"
placeholder="https://"
value={image}
onChange={(e) => setImage(e.target.value)} />
</div>
</div>}
{(options?.canSetStream === undefined || options.canSetStream) && <div>
<p>Stream Url</p>
<div className="paper">
<input
type="text"
placeholder="https://"
value={stream}
onChange={(e) => setStream(e.target.value)} />
</div>
<small>Stream type should be HLS</small>
</div>}
{(options?.canSetStatus === undefined || options.canSetStatus) && <><div>
<p>Status</p>
<div className="flex g12">
{[StreamState.Live, StreamState.Planned, StreamState.Ended].map(
(v) => (
<span
className={`pill${status === v ? " active" : ""}`}
onClick={() => setStatus(v)}
key={v}
>
{v}
</span>
)
)}
</div>
</div>
{status === StreamState.Planned && (
<div>
<p>Start Time</p>
<div className="input">
<input
type="datetime-local"
value={toDateTimeString(Number(start ?? "0"))}
onChange={(e) => setStart(fromDateTimeString(e.target.value).toString())} />
</div>
</div>
)}</>}
<div>
<AsyncButton
type="button"
className="btn btn-primary wide"
disabled={!isValid}
onClick={publishStream}
>
{ev ? "Save" : "Start Stream"}
</AsyncButton>
</div>
</>
);
}

View File

@ -0,0 +1,6 @@
import { StreamProviderStore } from "providers";
import { useSyncExternalStore } from "react";
export function useStreamProvider() {
return useSyncExternalStore(c => StreamProviderStore.hook(c), () => StreamProviderStore.snapshot());
}

View File

@ -51,6 +51,10 @@ const router = createBrowserRouter([
path: "/live/:id", path: "/live/:id",
element: <StreamPage />, element: <StreamPage />,
}, },
{
path: "/:id",
element: <StreamPage />,
},
{ {
path: "/providers/:id?", path: "/providers/:id?",
element: <StreamProvidersPage />, element: <StreamProvidersPage />,

View File

@ -2,9 +2,6 @@ import { Icon } from "element/icon";
import "./layout.css"; import "./layout.css";
import { import {
EventPublisher, EventPublisher,
NostrEvent,
encodeTLV,
NostrPrefix,
} from "@snort/system"; } from "@snort/system";
import { Outlet, useNavigate, useLocation } from "react-router-dom"; import { Outlet, useNavigate, useLocation } from "react-router-dom";
import AsyncButton from "element/async-button"; import AsyncButton from "element/async-button";
@ -30,7 +27,7 @@ export function LayoutPage() {
return ( return (
<> <>
<NewStreamDialog btnClassName="btn btn-primary" onFinish={goToStream} /> <NewStreamDialog btnClassName="btn btn-primary" />
<Profile <Profile
avatarClassname="mb-squared" avatarClassname="mb-squared"
pubkey={login.pubkey} pubkey={login.pubkey}
@ -55,22 +52,10 @@ export function LayoutPage() {
); );
} }
function goToStream(ev: NostrEvent) {
const d = ev.tags.find((t) => t.at(0) === "d")?.at(1) || "";
const naddr = encodeTLV(
NostrPrefix.Address,
d,
undefined,
ev.kind,
ev.pubkey
);
navigate(`/${naddr}`);
}
return ( return (
<div <div
className={ className={
location.pathname === "/" || location.pathname.startsWith("/p/") location.pathname === "/" || location.pathname.startsWith("/p/") || location.pathname.startsWith("/providers")
? "page only-content" ? "page only-content"
: location.pathname.startsWith("/chat/") : location.pathname.startsWith("/chat/")
? "page chat" ? "page chat"

View File

@ -1,7 +1,3 @@
.stream-providers-page {
padding: 40px;
}
.stream-providers-grid { .stream-providers-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);

View File

@ -5,6 +5,7 @@ import Owncast from "owncast.png";
import Cloudflare from "cloudflare.png"; import Cloudflare from "cloudflare.png";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ConfigureOwncast } from "./owncast"; import { ConfigureOwncast } from "./owncast";
import { ConfigureNostrType } from "./nostr";
export function StreamProvidersPage() { export function StreamProvidersPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -14,6 +15,7 @@ export function StreamProvidersPage() {
switch (p) { switch (p) {
case StreamProviders.Owncast: return "Owncast" case StreamProviders.Owncast: return "Owncast"
case StreamProviders.Cloudflare: return "Cloudflare" case StreamProviders.Cloudflare: return "Cloudflare"
case StreamProviders.NostrType: return "Nostr Native"
} }
return "Unknown" return "Unknown"
} }
@ -40,7 +42,7 @@ export function StreamProvidersPage() {
<h1>Providers</h1> <h1>Providers</h1>
<p>Stream providers streamline the process of streaming on Nostr, some event accept lightning payments!</p> <p>Stream providers streamline the process of streaming on Nostr, some event accept lightning payments!</p>
<div className="stream-providers-grid"> <div className="stream-providers-grid">
{[StreamProviders.Owncast, StreamProviders.Cloudflare].map(v => providerLink(v))} {[StreamProviders.NostrType, StreamProviders.Owncast, StreamProviders.Cloudflare].map(v => providerLink(v))}
</div> </div>
</div > </div >
} }
@ -52,6 +54,9 @@ export function StreamProvidersPage() {
case StreamProviders.Owncast: { case StreamProviders.Owncast: {
return <ConfigureOwncast /> return <ConfigureOwncast />
} }
case StreamProviders.NostrType: {
return <ConfigureNostrType />
}
} }
} }
} }

View File

@ -0,0 +1,83 @@
import AsyncButton from "element/async-button";
import { StatePill } from "element/state-pill";
import { StreamState } from "index";
import { StreamProviderInfo, StreamProviderStore } from "providers";
import { Nip103StreamProvider } from "providers/nip103";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
export function ConfigureNostrType() {
const [url, setUrl] = useState("");
const [info, setInfo] = useState<StreamProviderInfo>();
const navigate = useNavigate();
async function tryConnect() {
try {
const api = new Nip103StreamProvider(url);
const inf = await api.info();
setInfo(inf);
} catch (e) {
console.error(e);
}
}
function status() {
if (!info) return;
return <>
<h3>Status</h3>
<div>
<StatePill state={info?.state ?? StreamState.Ended} />
</div>
<div>
<p>Name</p>
<div className="paper">
{info?.name}
</div>
</div>
{info?.summary && <div>
<p>Summary</p>
<div className="paper">
{info?.summary}
</div>
</div>}
{info?.viewers && <div>
<p>Viewers</p>
<div className="paper">
{info?.viewers}
</div>
</div>}
{info?.version && <div>
<p>Version</p>
<div className="paper">
{info?.version}
</div>
</div>}
<div>
<button className="btn btn-border" onClick={() => {
StreamProviderStore.add(new Nip103StreamProvider(url));
navigate("/");
}}>
Save
</button>
</div>
</>
}
return <div className="owncast-config">
<div className="flex f-col g24">
<div>
<p>Nostr streaming provider URL</p>
<div className="paper">
<input type="text" placeholder="https://" value={url} onChange={e => setUrl(e.target.value)} />
</div>
</div>
<AsyncButton className="btn btn-primary" onClick={tryConnect}>
Connect
</AsyncButton>
</div>
<div>
{status()}
</div>
</div>
}

View File

@ -1,14 +1,16 @@
import AsyncButton from "element/async-button"; import AsyncButton from "element/async-button";
import { StatePill } from "element/state-pill"; import { StatePill } from "element/state-pill";
import { StreamState } from "index"; import { StreamState } from "index";
import { StreamProviderInfo } from "providers"; import { StreamProviderInfo, StreamProviderStore } from "providers";
import { OwncastProvider } from "providers/owncast"; import { OwncastProvider } from "providers/owncast";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom";
export function ConfigureOwncast() { export function ConfigureOwncast() {
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const [token, setToken] = useState(""); const [token, setToken] = useState("");
const [info, setInfo] = useState<StreamProviderInfo>(); const [info, setInfo] = useState<StreamProviderInfo>();
const navigate = useNavigate();
async function tryConnect() { async function tryConnect() {
try { try {
@ -54,7 +56,10 @@ export function ConfigureOwncast() {
</div> </div>
</div>} </div>}
<div> <div>
<button className="btn btn-border"> <button className="btn btn-border" onClick={() => {
StreamProviderStore.add(new OwncastProvider(url, token));
navigate("/");
}}>
Save Save
</button> </button>
</div> </div>

View File

@ -1,6 +1,14 @@
import { StreamState } from "index" import { StreamState } from "index"
import { NostrEvent } from "@snort/system";
import { ExternalStore } from "@snort/shared";
import { Nip103StreamProvider } from "./nip103";
import { ManualProvider } from "./manual";
import { OwncastProvider } from "./owncast";
export interface StreamProvider { export interface StreamProvider {
get name(): string
/** /**
* Get general info about connected provider to test everything is working * Get general info about connected provider to test everything is working
*/ */
@ -10,17 +18,74 @@ export interface StreamProvider {
* Create a config object to save in localStorage * Create a config object to save in localStorage
*/ */
createConfig(): any & { type: StreamProviders } createConfig(): any & { type: StreamProviders }
/**
* Update stream info event
*/
updateStreamInfo(ev: NostrEvent): Promise<void>;
} }
export enum StreamProviders { export enum StreamProviders {
Manual = "manual",
Owncast = "owncast", Owncast = "owncast",
Cloudflare = "cloudflare" Cloudflare = "cloudflare",
NostrType = "nostr"
} }
export interface StreamProviderInfo { export interface StreamProviderInfo {
type: StreamProviders
name: string name: string
summary?: string summary?: string
version?: string version?: string
state: StreamState state: StreamState
viewers: number viewers?: number
ingressUrl?: string
ingressKey?: string
balance?: number
publishedEvent?: NostrEvent
} }
export class ProviderStore extends ExternalStore<Array<StreamProvider>> {
#providers: Array<StreamProvider> = []
constructor() {
super();
const cache = window.localStorage.getItem("providers");
if (cache) {
const cached: Array<{ type: StreamProviders } & any> = JSON.parse(cache);
for (const c of cached) {
switch (c.type) {
case StreamProviders.Manual: {
this.#providers.push(new ManualProvider());
break;
}
case StreamProviders.NostrType: {
this.#providers.push(new Nip103StreamProvider(c.url));
break;
}
case StreamProviders.Owncast: {
this.#providers.push(new OwncastProvider(c.url, c.token));
break;
}
}
}
}
}
add(p: StreamProvider) {
this.#providers.push(p);
this.#save();
this.notifyChange();
}
takeSnapshot() {
return [new ManualProvider(), ...this.#providers];
}
#save() {
const cfg = this.#providers.map(a => a.createConfig());
window.localStorage.setItem("providers", JSON.stringify(cfg));
}
}
export const StreamProviderStore = new ProviderStore();

26
src/providers/manual.ts Normal file
View File

@ -0,0 +1,26 @@
import { NostrEvent } from "@snort/system";
import { System } from "index";
import { StreamProvider, StreamProviderInfo, StreamProviders } from "providers";
export class ManualProvider implements StreamProvider {
get name(): string {
return "Manual"
}
info(): Promise<StreamProviderInfo> {
return Promise.resolve({
type: StreamProviders.Manual,
name: this.name
} as StreamProviderInfo)
}
createConfig() {
return {
type: StreamProviders.Manual
}
}
updateStreamInfo(ev: NostrEvent): Promise<void> {
System.BroadcastEvent(ev);
return Promise.resolve();
}
}

84
src/providers/nip103.ts Normal file
View File

@ -0,0 +1,84 @@
import { StreamProvider, StreamProviderInfo, StreamProviders } from ".";
import { EventPublisher, EventKind, NostrEvent } from "@snort/system";
import { findTag } from "utils";
export class Nip103StreamProvider implements StreamProvider {
#url: string
constructor(url: string) {
this.#url = url;
}
get name() {
return new URL(this.#url).host;
}
async info() {
const rsp = await this.#getJson<AccountResponse>("GET", "account");
const title = findTag(rsp.event, "title");
const state = findTag(rsp.event, "status");
return {
type: StreamProviders.NostrType,
name: title ?? "",
state: state,
viewers: 0,
ingressUrl: rsp.url,
ingressKey: rsp.key,
balance: rsp.quota.remaining,
publishedEvent: rsp.event
} as StreamProviderInfo
}
createConfig() {
return {
type: StreamProviders.NostrType,
url: this.#url
}
}
async updateStreamInfo(ev: NostrEvent): Promise<void> {
const title = findTag(ev, "title");
const summary = findTag(ev, "summary");
const image = findTag(ev, "image");
await this.#getJson("PATCH", "event", {
title, summary, image
});
}
async #getJson<T>(method: "GET" | "POST" | "PATCH", path: string, body?: unknown): Promise<T> {
const pub = await EventPublisher.nip7();
if (!pub) throw new Error("No event publisher");
const u = `${this.#url}${path}`;
const token = await pub.generic(eb => {
return eb.kind(EventKind.HttpAuthentication)
.content("")
.tag(["u", u])
.tag(["method", method])
});
const rsp = await fetch(u, {
method: method,
body: body ? JSON.stringify(body) : undefined,
headers: {
"content-type": "application/json",
"authorization": `Nostr ${btoa(JSON.stringify(token))}`
},
});
const json = await rsp.text();
if (!rsp.ok) {
throw new Error(json);
}
return json.length > 0 ? JSON.parse(json) as T : {} as T;
}
}
interface AccountResponse {
url: string
key: string
event?: NostrEvent
quota: {
unit: string
rate: number
remaining: number
}
}

View File

@ -1,3 +1,4 @@
import { NostrEvent } from "@snort/system";
import { StreamState } from "index"; import { StreamState } from "index";
import { StreamProvider, StreamProviderInfo, StreamProviders } from "providers"; import { StreamProvider, StreamProviderInfo, StreamProviders } from "providers";
@ -10,6 +11,10 @@ export class OwncastProvider implements StreamProvider {
this.#token = token; this.#token = token;
} }
get name() {
return new URL(this.#url).host
}
createConfig(): any & { type: StreamProviders; } { createConfig(): any & { type: StreamProviders; } {
return { return {
type: StreamProviders.Owncast, type: StreamProviders.Owncast,
@ -18,10 +23,15 @@ export class OwncastProvider implements StreamProvider {
} }
} }
updateStreamInfo(ev: NostrEvent): Promise<void> {
return Promise.resolve();
}
async info() { async info() {
const info = await this.#getJson<ConfigResponse>("GET", "/api/config"); const info = await this.#getJson<ConfigResponse>("GET", "/api/config");
const status = await this.#getJson<StatusResponse>("GET", "/api/status"); const status = await this.#getJson<StatusResponse>("GET", "/api/status");
return { return {
type: StreamProviders.Owncast,
name: info.name, name: info.name,
summary: info.summary, summary: info.summary,
version: info.version, version: info.version,

View File

@ -40,3 +40,15 @@ export function splitByUrl(str: string) {
return str.split(urlRegex); return str.split(urlRegex);
} }
export function eventLink(ev: NostrEvent) {
const d = findTag(ev, "d") ?? "";
const naddr = encodeTLV(
NostrPrefix.Address,
d,
undefined,
ev.kind,
ev.pubkey
);
return `/${naddr}`;
}