fix: video upload

feat: use new video/short kinds
This commit is contained in:
kieran 2025-01-28 15:13:41 +00:00
parent 4bade72dd6
commit de88158b3a
No known key found for this signature in database
GPG Key ID: DE71CEB3925BE941
17 changed files with 219 additions and 127 deletions

View File

@ -8,11 +8,11 @@
"@noble/hashes": "^1.4.0", "@noble/hashes": "^1.4.0",
"@scure/base": "^1.1.6", "@scure/base": "^1.1.6",
"@snort/shared": "^1.0.17", "@snort/shared": "^1.0.17",
"@snort/system": "^1.5.6", "@snort/system": "^1.6.1",
"@snort/system-react": "^1.5.6", "@snort/system-react": "^1.6.1",
"@snort/system-wasm": "^1.0.5", "@snort/system-wasm": "^1.0.5",
"@snort/wallet": "^0.2.1", "@snort/wallet": "^0.2.4",
"@snort/worker-relay": "^1.3.0", "@snort/worker-relay": "^1.3.1",
"@szhsin/react-menu": "^4.1.0", "@szhsin/react-menu": "^4.1.0",
"@types/webscopeio__react-textarea-autocomplete": "^4.7.5", "@types/webscopeio__react-textarea-autocomplete": "^4.7.5",
"@webscopeio/react-textarea-autocomplete": "^4.9.2", "@webscopeio/react-textarea-autocomplete": "^4.9.2",

View File

@ -8,8 +8,10 @@ export const GOAL = 9041 as EventKind;
export const USER_CARDS = 17_777 as EventKind; export const USER_CARDS = 17_777 as EventKind;
export const CARD = 37_777 as EventKind; export const CARD = 37_777 as EventKind;
export const VIDEO_KIND = 34_235 as EventKind; export const VIDEO_KIND = 21 as EventKind;
export const SHORTS_KIND = 34_236 as EventKind; export const SHORTS_KIND = 22 as EventKind;
export const OLD_VIDEO_KIND = 34_235 as EventKind;
export const OLD_SHORTS_KIND = 34_236 as EventKind;
export const MINUTE = 60; export const MINUTE = 60;
export const HOUR = 60 * MINUTE; export const HOUR = 60 * MINUTE;

View File

@ -5,13 +5,15 @@ import { Goal } from "./goal";
import { Note } from "./note"; import { Note } from "./note";
import { EmojiPack } from "./emoji-pack"; import { EmojiPack } from "./emoji-pack";
import { BadgeInfo } from "./badge"; import { BadgeInfo } from "./badge";
import { GOAL, LIVE_STREAM_CLIP, StreamState } from "@/const"; import { GOAL, LIVE_STREAM_CLIP, OLD_SHORTS_KIND, OLD_VIDEO_KIND, SHORTS_KIND, StreamState, VIDEO_KIND } from "@/const";
import { useEventFeed } from "@snort/system-react"; import { useEventFeed } from "@snort/system-react";
import LiveStreamClip from "./stream/clip"; import LiveStreamClip from "./stream/clip";
import { ExternalLink } from "./external-link"; import { ExternalLink } from "./external-link";
import { extractStreamInfo } from "@/utils"; import { extractStreamInfo } from "@/utils";
import LiveVideoPlayer from "./stream/live-video-player"; import LiveVideoPlayer from "./stream/live-video-player";
import { HTMLProps } from "react"; import { HTMLProps, ReactNode } from "react";
import { ShortPage } from "@/pages/short";
import { VideoPage } from "@/pages/video";
interface EventProps { interface EventProps {
link: NostrLink; link: NostrLink;
@ -31,36 +33,49 @@ export function EventIcon({ kind }: { kind?: EventKind }) {
} }
export function NostrEvent({ ev }: { ev: TaggedNostrEvent }) { export function NostrEvent({ ev }: { ev: TaggedNostrEvent }) {
const link = NostrLink.fromEvent(ev);
function modalPage(inner: ReactNode) {
return <div className="rounded-2xl px-4 py-3 md:w-[700px] mx-auto w-full">{inner}</div>;
}
switch (ev.kind) { switch (ev.kind) {
case GOAL: { case GOAL: {
return <Goal ev={ev} />; return modalPage(<Goal ev={ev} />);
} }
case EventKind.EmojiSet: { case EventKind.EmojiSet: {
return <EmojiPack ev={ev} />; return modalPage(<EmojiPack ev={ev} />);
} }
case EventKind.Badge: { case EventKind.Badge: {
return <BadgeInfo ev={ev} />; return modalPage(<BadgeInfo ev={ev} />);
} }
case EventKind.TextNote: { case EventKind.TextNote: {
return <Note ev={ev} />; return modalPage(<Note ev={ev} />);
} }
case LIVE_STREAM_CLIP: { case LIVE_STREAM_CLIP: {
return <LiveStreamClip ev={ev} />; return modalPage(<LiveStreamClip ev={ev} />);
}
case OLD_SHORTS_KIND:
case SHORTS_KIND: {
return <ShortPage link={link} evPreload={ev} />;
}
case OLD_VIDEO_KIND:
case VIDEO_KIND: {
return <VideoPage link={link} evPreload={ev} />;
} }
case EventKind.LiveEvent: { case EventKind.LiveEvent: {
const info = extractStreamInfo(ev); const info = extractStreamInfo(ev);
return ( return modalPage(
<LiveVideoPlayer <LiveVideoPlayer
link={link}
title={info.title} title={info.title}
status={info.status} status={info.status}
stream={info.status === StreamState.Live ? info.stream : info.recording} stream={info.status === StreamState.Live ? info.stream : info.recording}
poster={info.image} poster={info.image}
/> />,
); );
} }
default: { default: {
const link = NostrLink.fromEvent(ev); return modalPage(<ExternalLink href={`https://snort.social/${link.encode()}`}>{link.encode()}</ExternalLink>);
return <ExternalLink href={`https://snort.social/${link.encode()}`}>{link.encode()}</ExternalLink>;
} }
} }
} }

View File

@ -145,7 +145,7 @@ export function StreamEditor({ ev, onFinish, options }: StreamEditorProps) {
} }
} }
const startsTimestamp = parseInt(start ?? (new Date() / 1000)); const startsTimestamp = Number(start ?? (new Date().getTime() / 1000));
const startsDate = new Date(startsTimestamp * 1000); const startsDate = new Date(startsTimestamp * 1000);
return ( return (

View File

@ -1,13 +1,10 @@
import { unwrap } from "@snort/shared";
import { NostrLink, NostrPrefix, RequestBuilder, TaggedNostrEvent } from "@snort/system"; import { NostrLink, NostrPrefix, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react"; import { useRequestBuilder } from "@snort/system-react";
import { useMemo } from "react"; import { useMemo } from "react";
import { LIVE_STREAM } from "@/const"; import { LIVE_STREAM } from "@/const";
import { getHost } from "@/utils";
export function useCurrentStreamFeed(link: NostrLink, leaveOpen = false, evPreload?: TaggedNostrEvent) { export function useCurrentStreamFeed(link: NostrLink, leaveOpen = false, evPreload?: TaggedNostrEvent) {
const author = link.type === NostrPrefix.Address ? unwrap(link.author) : link.id;
const sub = useMemo(() => { const sub = useMemo(() => {
const b = new RequestBuilder(`current-event:${link.id}`); const b = new RequestBuilder(`current-event:${link.id}`);
b.withOptions({ b.withOptions({
@ -16,14 +13,8 @@ export function useCurrentStreamFeed(link: NostrLink, leaveOpen = false, evPrelo
if (link.type === NostrPrefix.PublicKey || link.type === NostrPrefix.Profile) { if (link.type === NostrPrefix.PublicKey || link.type === NostrPrefix.Profile) {
b.withFilter().authors([link.id]).kinds([LIVE_STREAM]); b.withFilter().authors([link.id]).kinds([LIVE_STREAM]);
b.withFilter().tag("p", [link.id]).kinds([LIVE_STREAM]); b.withFilter().tag("p", [link.id]).kinds([LIVE_STREAM]);
} else if (link.type === NostrPrefix.Address) { } else {
const f = b.withFilter().tag("d", [link.id]); b.withFilter().link(link);
if (link.author) {
f.authors([link.author]);
}
if (link.kind) {
f.kinds([link.kind]);
}
} }
return b; return b;
}, [link.id, leaveOpen]); }, [link.id, leaveOpen]);
@ -31,9 +22,7 @@ export function useCurrentStreamFeed(link: NostrLink, leaveOpen = false, evPrelo
const q = useRequestBuilder(sub); const q = useRequestBuilder(sub);
return useMemo(() => { return useMemo(() => {
const hosting = [...q, ...(evPreload ? [evPreload] : [])] const hosting = [...q, ...(evPreload ? [evPreload] : [])].sort((a, b) => (b.created_at > a.created_at ? 1 : -1));
.filter(a => getHost(a) === author || a.pubkey === author)
.sort((a, b) => (b.created_at > a.created_at ? 1 : -1));
return hosting.at(0); return hosting.at(0);
}, [q]); }, [q]);
} }

View File

@ -1,4 +1,4 @@
import { SHORTS_KIND, VIDEO_KIND } from "@/const"; import { OLD_SHORTS_KIND, OLD_VIDEO_KIND, SHORTS_KIND, VIDEO_KIND } from "@/const";
import { MediaPayload, VideoInfo } from "@/service/video/info"; import { MediaPayload, VideoInfo } from "@/service/video/info";
import { findTag } from "@/utils"; import { findTag } from "@/utils";
import { NostrEvent, TaggedNostrEvent } from "@snort/system"; import { NostrEvent, TaggedNostrEvent } from "@snort/system";
@ -58,7 +58,7 @@ export function useDeadLink(ev: TaggedNostrEvent | NostrEvent) {
useEffect(() => { useEffect(() => {
const links = const links =
ev.kind === VIDEO_KIND || ev.kind === SHORTS_KIND ev.kind === VIDEO_KIND || ev.kind === SHORTS_KIND || ev.kind == OLD_SHORTS_KIND || ev.kind == OLD_VIDEO_KIND
? VideoInfo.parse(ev)?.sources() ? VideoInfo.parse(ev)?.sources()
: [ : [
{ {

View File

@ -4,12 +4,7 @@ import { removeUndefined, sanitizeRelayUrl } from "@snort/shared";
import { Nip96Server } from "@/service/upload/nip96"; import { Nip96Server } from "@/service/upload/nip96";
import { useMemo } from "react"; import { useMemo } from "react";
export const DefaultMediaServers = [ export const DefaultMediaServers = [new UnknownTag(["server", "https://nostr.download/"])];
//"https://media.zap.stream",
new UnknownTag(["server", "https://nostr.build/"]),
new UnknownTag(["server", "https://nostrcheck.me/"]),
new UnknownTag(["server", "https://files.v0l.io/"]),
];
export function useMediaServerList() { export function useMediaServerList() {
const login = useLogin(); const login = useLogin();

View File

@ -221,6 +221,9 @@
"BD0vyn": { "BD0vyn": {
"defaultMessage": "{name} created a clip" "defaultMessage": "{name} created a clip"
}, },
"BHNL+v": {
"defaultMessage": "Raw Data:"
},
"Bd1yEX": { "Bd1yEX": {
"defaultMessage": "New Stream Goal" "defaultMessage": "New Stream Goal"
}, },
@ -641,6 +644,9 @@
"ZcgtZo": { "ZcgtZo": {
"defaultMessage": "We've put together a list of easy-to-use exchanges that will allow you to buy a small amount of bitcoin (sats) and to transfer them to your wallet of choice." "defaultMessage": "We've put together a list of easy-to-use exchanges that will allow you to buy a small amount of bitcoin (sats) and to transfer them to your wallet of choice."
}, },
"Zfr//4": {
"defaultMessage": "No duration provided, please try another upload server."
},
"ZmqxZs": { "ZmqxZs": {
"defaultMessage": "You can change this later" "defaultMessage": "You can change this later"
}, },
@ -705,6 +711,9 @@
"dkUMIH": { "dkUMIH": {
"defaultMessage": "Clip by {name}" "defaultMessage": "Clip by {name}"
}, },
"dqGkI+": {
"defaultMessage": "Video durations vary too much, are you sure each variant is the same video?"
},
"e011kf": { "e011kf": {
"defaultMessage": "FAQ", "defaultMessage": "FAQ",
"description": "Title: FAQ page" "description": "Title: FAQ page"
@ -945,6 +954,9 @@
"vP4dFa": { "vP4dFa": {
"defaultMessage": "Visit {link} to get some sweet zap.stream merch!" "defaultMessage": "Visit {link} to get some sweet zap.stream merch!"
}, },
"vaZKTn": {
"defaultMessage": "Add more content"
},
"vrTOHJ": { "vrTOHJ": {
"defaultMessage": "{amount} sats" "defaultMessage": "{amount} sats"
}, },

View File

@ -1,4 +1,4 @@
import { LIVE_STREAM, SHORTS_KIND, VIDEO_KIND } from "@/const"; import { LIVE_STREAM, OLD_SHORTS_KIND, OLD_VIDEO_KIND, SHORTS_KIND, VIDEO_KIND } from "@/const";
import { useStreamLink } from "@/hooks/stream-link"; import { useStreamLink } from "@/hooks/stream-link";
import { getEventFromLocationState } from "@/utils"; import { getEventFromLocationState } from "@/utils";
import { NostrPrefix } from "@snort/system"; import { NostrPrefix } from "@snort/system";
@ -20,20 +20,16 @@ export function LinkHandler() {
if (!link) return; if (!link) return;
if (link.type === NostrPrefix.Event) { if (link.type === NostrPrefix.Event) {
return ( return <NostrEventElement link={link} />;
<div className="rounded-2xl px-4 py-3 md:w-[700px] mx-auto w-full">
<NostrEventElement link={link} />
</div>
);
} else if (link.kind === LIVE_STREAM || link.type === NostrPrefix.PublicKey) { } else if (link.kind === LIVE_STREAM || link.type === NostrPrefix.PublicKey) {
return ( return (
<div className={classNames(layoutContext.showHeader ? "h-[calc(100dvh-44px)]" : "h-[calc(100dvh)]", "w-full")}> <div className={classNames(layoutContext.showHeader ? "h-[calc(100dvh-44px)]" : "h-[calc(100dvh)]", "w-full")}>
<StreamPage link={link} evPreload={evPreload} /> <StreamPage link={link} evPreload={evPreload} />
</div> </div>
); );
} else if (link.kind === VIDEO_KIND) { } else if (link.kind === VIDEO_KIND || link.kind === OLD_VIDEO_KIND) {
return <VideoPage link={link} evPreload={evPreload} />; return <VideoPage link={link} evPreload={evPreload} />;
} else if (link.kind === SHORTS_KIND) { } else if (link.kind === SHORTS_KIND || link.kind === OLD_SHORTS_KIND) {
return <ShortPage link={link} evPreload={evPreload} />; return <ShortPage link={link} evPreload={evPreload} />;
} else { } else {
return ( return (

View File

@ -1,4 +1,4 @@
import { SHORTS_KIND } from "@/const"; import { OLD_SHORTS_KIND, SHORTS_KIND } from "@/const";
import VideoGrid from "@/element/video-grid"; import VideoGrid from "@/element/video-grid";
import { VideoTile } from "@/element/video/video-tile"; import { VideoTile } from "@/element/video/video-tile";
import { findTag } from "@/utils"; import { findTag } from "@/utils";
@ -8,13 +8,12 @@ import { FormattedMessage } from "react-intl";
export function ShortsPage() { export function ShortsPage() {
const rb = new RequestBuilder("shorts"); const rb = new RequestBuilder("shorts");
rb.withFilter().kinds([SHORTS_KIND]); rb.withFilter().kinds([SHORTS_KIND, OLD_SHORTS_KIND]);
const videos = useRequestBuilder(rb); const videos = useRequestBuilder(rb);
const sorted = videos.sort((a, b) => { const sorted = videos.sort((a, b) => {
const pubA = findTag(a, "published_at"); const pubA = findTag(a, "published_at") ?? a.created_at;
const pubB = findTag(b, "published_at"); const pubB = findTag(b, "published_at") ?? b.created_at;
return Number(pubA) > Number(pubB) ? -1 : 1; return Number(pubA) > Number(pubB) ? -1 : 1;
}); });

View File

@ -1,4 +1,4 @@
import { VIDEO_KIND } from "@/const"; import { SHORTS_KIND, VIDEO_KIND } from "@/const";
import { DefaultButton, IconButton, Layer3Button, PrimaryButton, WarningButton } from "@/element/buttons"; import { DefaultButton, IconButton, Layer3Button, PrimaryButton, WarningButton } from "@/element/buttons";
import { Icon } from "@/element/icon"; import { Icon } from "@/element/icon";
import Modal from "@/element/modal"; import Modal from "@/element/modal";
@ -9,11 +9,11 @@ import { ServerList } from "@/element/upload/server-list";
import useImgProxy from "@/hooks/img-proxy"; import useImgProxy from "@/hooks/img-proxy";
import { useLogin } from "@/hooks/login"; import { useLogin } from "@/hooks/login";
import { useMediaServerList } from "@/hooks/media-servers"; import { useMediaServerList } from "@/hooks/media-servers";
import { Nip94Tags, UploadResult, nip94TagsToIMeta } from "@/service/upload"; import { Nip94Tags, UploadResult, nip94TagsToIMeta, readNip94Tags } from "@/service/upload";
import { Nip96Server } from "@/service/upload/nip96"; import { Nip96Server } from "@/service/upload/nip96";
import { openFile } from "@/utils"; import { openFile } from "@/utils";
import { ExternalStore, removeUndefined, unixNow, unwrap } from "@snort/shared"; import { ExternalStore, removeUndefined, unwrap } from "@snort/shared";
import { EventPublisher, NostrLink } from "@snort/system"; import { EventBuilder, EventPublisher, NostrEvent, NostrLink } from "@snort/system";
import { SnortContext } from "@snort/system-react"; import { SnortContext } from "@snort/system-react";
import { useContext, useEffect, useState, useSyncExternalStore } from "react"; import { useContext, useEffect, useState, useSyncExternalStore } from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
@ -69,6 +69,23 @@ class UploadManager extends ExternalStore<Array<UploadStatus>> {
} }
} }
addUpload(server: string, file: NostrEvent, meta: Nip94Tags, type: UploadStatus["type"]) {
const name = file.content ?? meta.summary ?? meta.alt ?? "";
const uploadKey = `${name}:${server}:${type}`;
this.#uploads.set(uploadKey, {
type,
name,
size: meta.size ?? 0,
server,
result: {
url: meta.url,
header: file,
metadata: meta,
},
});
this.notifyChange();
}
async uploadTo(server: string, file: File, pub: EventPublisher, type: UploadStatus["type"]) { async uploadTo(server: string, file: File, pub: EventPublisher, type: UploadStatus["type"]) {
let uploader = this.#uploaders.get(server); let uploader = this.#uploaders.get(server);
if (!uploader) { if (!uploader) {
@ -130,6 +147,27 @@ class UploadManager extends ExternalStore<Array<UploadStatus>> {
return resGroup; return resGroup;
} }
/**
* Gets the [min, max] duration from all variants
*/
duration() {
const uploads = this.snapshot();
return uploads.reduce(
(acc, v) => {
if (v.result?.metadata?.duration) {
if (acc[1] < v.result.metadata.duration) {
acc[1] = v.result.metadata.duration;
}
if (acc[0] > v.result.metadata.duration) {
acc[0] = v.result.metadata.duration;
}
}
return acc;
},
[1_000_000, 0],
);
}
/** /**
* Create the `imeta` tag for this upload * Create the `imeta` tag for this upload
*/ */
@ -187,21 +225,24 @@ export function UploadPage() {
return error.length == 0 && uploads.length > 0 && uploads.every(a => a.result !== undefined); return error.length == 0 && uploads.length > 0 && uploads.every(a => a.result !== undefined);
} }
async function publish() { function makeEvent() {
const pub = login?.publisher(); const duration = manager.duration();
if (!pub) return; const eb = new EventBuilder()
const ev = await pub.generic(eb => { .pubKey(login?.pubkey ?? "00".repeat(31))
eb.kind(VIDEO_KIND); .kind(duration[1] <= 60 ? SHORTS_KIND : VIDEO_KIND)
eb.tag(["d", manager.id]); .tag(["title", title])
eb.tag(["title", title]); .content(summary);
eb.tag(["published_at", unixNow().toString()]);
eb.content(summary);
const imeta = manager.makeIMeta(); const imeta = manager.makeIMeta();
imeta.forEach(a => eb.tag(a)); imeta.forEach(a => eb.tag(a));
return eb; return eb;
}); }
async function publish() {
const pub = login?.publisher();
if (!pub) return;
const ev = await makeEvent().buildAndSign(pub.signer);
console.debug(ev); console.debug(ev);
await system.BroadcastEvent(ev); await system.BroadcastEvent(ev);
navigate(`/${NostrLink.fromEvent(ev).encode()}`); navigate(`/${NostrLink.fromEvent(ev).encode()}`);
@ -229,7 +270,16 @@ export function UploadPage() {
const data = await rsp.blob(); const data = await rsp.blob();
const pub = login?.publisher(); const pub = login?.publisher();
if (pub) { if (pub) {
servers.servers.forEach(b => manager.uploadTo(b, new File([data], "thumb.jpg"), pub, "thumb")); servers.servers.forEach(b =>
manager.uploadTo(
b,
new File([data], "thumb.jpg", {
type: "image/jpeg",
}),
pub,
"thumb",
),
);
} }
} }
} }
@ -291,6 +341,17 @@ export function UploadPage() {
}), }),
); );
} }
const d = manager.duration();
if (d[0] === 0 || d[1] === 0) {
err.push(formatMessage({ defaultMessage: "No duration provided, please try another upload server." }));
}
if (Math.abs(d[0] - d[1]) >= 0.5) {
err.push(
formatMessage({
defaultMessage: "Video durations vary too much, are you sure each variant is the same video?",
}),
);
}
setError(err); setError(err);
} }
@ -312,6 +373,7 @@ export function UploadPage() {
</> </>
); );
}; };
return ( return (
<div className="max-xl:w-full xl:w-[1200px] xl:mx-auto grid gap-6 xl:grid-cols-[auto_350px] max-xl:px-4"> <div className="max-xl:w-full xl:w-[1200px] xl:mx-auto grid gap-6 xl:grid-cols-[auto_350px] max-xl:px-4">
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@ -359,8 +421,11 @@ export function UploadPage() {
))} ))}
</div> </div>
{videos > 0 && ( {videos > 0 && (
<div onClick={() => uploadFile()} className="cursor-pointer"> <div className="flex flex-col gap-2">
{uploadButton()} <div className="text-xl">
<FormattedMessage defaultMessage="Add more content" />
</div>
<div className="flex gap-4 items-center">{uploadButton()}</div>
</div> </div>
)} )}
{uploads.length > 0 && ( {uploads.length > 0 && (
@ -377,7 +442,7 @@ export function UploadPage() {
<FormattedMessage defaultMessage="Thumbnail" /> <FormattedMessage defaultMessage="Thumbnail" />
</div> </div>
<div className="border border-layer-3 border-dashed border-2 rounded-xl aspect-video overflow-hidden"> <div className="border border-layer-3 border-dashed border-2 rounded-xl aspect-video overflow-hidden">
{thumb && <img src={proxy(thumb)} className="w-full h-full" />} {thumb && <img src={proxy(thumb)} className="w-full h-full object-contain" />}
</div> </div>
<div className="flex gap-4"> <div className="flex gap-4">
<DefaultButton onClick={() => uploadThumb()}> <DefaultButton onClick={() => uploadThumb()}>
@ -405,9 +470,13 @@ export function UploadPage() {
</WarningButton> </WarningButton>
</div> </div>
</div> </div>
<div>
<FormattedMessage defaultMessage="Raw Data:" />
<pre className="text-xs font-mono overflow-wrap text-pretty"> <pre className="text-xs font-mono overflow-wrap text-pretty">
{JSON.stringify(manager.makeIMeta(), undefined, 2)} {JSON.stringify(makeEvent().build(), undefined, 2)}
</pre> </pre>
</div>
{editServers && ( {editServers && (
<Modal id="server-list" onClose={() => setEditServers(false)}> <Modal id="server-list" onClose={() => setEditServers(false)}>
<ServerList /> <ServerList />
@ -416,7 +485,19 @@ export function UploadPage() {
{mediaPicker && ( {mediaPicker && (
<Modal id="media-picker" onClose={() => setMediaPicker(false)} largeModal={true}> <Modal id="media-picker" onClose={() => setMediaPicker(false)} largeModal={true}>
<MediaServerFileList <MediaServerFileList
onPicked={() => { onPicked={files => {
files.forEach(f => {
const meta = readNip94Tags(f.tags);
if (meta.url) {
const url = new URL(meta.url);
manager.addUpload(
`${url.protocol}//${url.host}/`,
f,
meta,
meta.mimeType?.startsWith("image/") ?? false ? "thumb" : "video",
);
}
});
setMediaPicker(false); setMediaPicker(false);
}} }}
/> />

View File

@ -1,4 +1,4 @@
import { VIDEO_KIND } from "@/const"; import { OLD_VIDEO_KIND, VIDEO_KIND } from "@/const";
import VideoGrid from "@/element/video-grid"; import VideoGrid from "@/element/video-grid";
import { findTag, getHost } from "@/utils"; import { findTag, getHost } from "@/utils";
import { NostrLink, RequestBuilder } from "@snort/system"; import { NostrLink, RequestBuilder } from "@snort/system";
@ -11,7 +11,7 @@ export function VideosPage() {
const login = useLogin(); const login = useLogin();
const rb = new RequestBuilder("videos"); const rb = new RequestBuilder("videos");
rb.withFilter().kinds([VIDEO_KIND]); rb.withFilter().kinds([VIDEO_KIND, OLD_VIDEO_KIND]);
const videos = useRequestBuilder(rb); const videos = useRequestBuilder(rb);
@ -22,8 +22,8 @@ export function VideosPage() {
return (login?.state?.muted.length ?? 0) === 0 || !login?.state?.muted.some(a => a.equals(link)); return (login?.state?.muted.length ?? 0) === 0 || !login?.state?.muted.some(a => a.equals(link));
}) })
.sort((a, b) => { .sort((a, b) => {
const pubA = findTag(a, "published_at"); const pubA = findTag(a, "published_at") ?? a.created_at;
const pubB = findTag(b, "published_at"); const pubB = findTag(b, "published_at") ?? b.created_at;
return Number(pubA) > Number(pubB) ? -1 : 1; return Number(pubA) > Number(pubB) ? -1 : 1;
}); });

View File

@ -16,6 +16,8 @@ export interface Nip94Tags {
summary?: string; summary?: string;
alt?: string; alt?: string;
fallback?: Array<string>; fallback?: Array<string>;
duration?: number;
bitrate?: number;
} }
export interface UploadResult { export interface UploadResult {
@ -103,6 +105,14 @@ export function readNip94Tags(tags: Array<Array<string>>) {
res.fallback.push(v); res.fallback.push(v);
break; break;
} }
case "duration": {
res.duration = Number(v);
break;
}
case "bitrate": {
res.bitrate = Number(v);
break;
}
} }
} }
return res; return res;
@ -126,6 +136,8 @@ export function nip94TagsToIMeta(meta: Nip94Tags) {
ifPush("thumb", meta.thumb); ifPush("thumb", meta.thumb);
ifPush("summary", meta.summary); ifPush("summary", meta.summary);
ifPush("alt", meta.alt); ifPush("alt", meta.alt);
ifPush("duration", meta.duration);
ifPush("bitrate", meta.bitrate);
if (meta.image) { if (meta.image) {
meta.image.forEach(a => ifPush("image", a)); meta.image.forEach(a => ifPush("image", a));
} }

View File

@ -37,7 +37,7 @@ export class Nip96Server {
const fd = new FormData(); const fd = new FormData();
fd.append("size", file.size.toString()); fd.append("size", file.size.toString());
fd.append("caption", filename); fd.append("caption", filename);
fd.append("media_type", file.type); fd.append("content_type", file.type);
fd.append("file", file); fd.append("file", file);
const rsp = await this.#req("", "POST", fd); const rsp = await this.#req("", "POST", fd);

View File

@ -2,7 +2,6 @@ import { NostrEvent } from "@snort/system";
import { GameInfo } from "../game-database"; import { GameInfo } from "../game-database";
import { Nip94Tags, readNip94Tags, readNip94TagsFromIMeta } from "../upload"; import { Nip94Tags, readNip94Tags, readNip94TagsFromIMeta } from "../upload";
import { getHost, sortStreamTags, extractGameTag, findTag } from "@/utils"; import { getHost, sortStreamTags, extractGameTag, findTag } from "@/utils";
import { unwrap } from "@snort/shared";
export interface MediaPayload { export interface MediaPayload {
url: string; url: string;
@ -19,9 +18,12 @@ export class VideoInfo {
goal?: string; goal?: string;
gameId?: string; gameId?: string;
gameInfo?: GameInfo; gameInfo?: GameInfo;
duration?: number;
publishedAt?: number; publishedAt?: number;
get duration() {
return this.media.find(m => m.duration)?.duration;
}
constructor( constructor(
readonly host: string, readonly host: string,
readonly id: string, readonly id: string,
@ -31,28 +33,13 @@ export class VideoInfo {
static parse(ev: NostrEvent) { static parse(ev: NostrEvent) {
const { regularTags, prefixedTags } = sortStreamTags(ev.tags); const { regularTags, prefixedTags } = sortStreamTags(ev.tags);
const ret = new VideoInfo(getHost(ev), unwrap(findTag(ev, "d")), regularTags, VideoInfo.#parseMediaTags(ev.tags)); const ret = new VideoInfo(getHost(ev), findTag(ev, "d") ?? ev.id, regularTags, VideoInfo.#parseMediaTags(ev.tags));
const matchInto = <K extends keyof VideoInfo>( ret.title = findTag(ev, "title");
tag: Array<string>, ret.summary = findTag(ev, "summary") ?? ev.content;
key: string, ret.contentWarning = findTag(ev, "content-warning");
into: K, ret.goal = findTag(ev, "goal");
fn?: (v: string) => never, ret.publishedAt = Number(findTag(ev, "published_at") ?? ev.created_at);
) => {
if (tag[0] === key) {
ret[into] = fn ? fn(tag[1]) : (tag[1] as never);
}
};
for (const t of ev.tags) {
matchInto(t, "d", "id");
matchInto(t, "title", "title");
matchInto(t, "summary", "summary");
matchInto(t, "content-warning", "contentWarning");
matchInto(t, "goal", "goal");
matchInto(t, "duration", "duration");
matchInto(t, "published_at", "publishedAt");
}
const { gameInfo, gameId } = extractGameTag(prefixedTags); const { gameInfo, gameId } = extractGameTag(prefixedTags);
ret.gameId = gameId; ret.gameId = gameId;

View File

@ -73,6 +73,7 @@
"Axo/o5": "Science & Technology", "Axo/o5": "Science & Technology",
"AyGauy": "Login", "AyGauy": "Login",
"BD0vyn": "{name} created a clip", "BD0vyn": "{name} created a clip",
"BHNL+v": "Raw Data:",
"Bd1yEX": "New Stream Goal", "Bd1yEX": "New Stream Goal",
"Bep/gA": "Private key", "Bep/gA": "Private key",
"BzQPM+": "Destination", "BzQPM+": "Destination",
@ -211,6 +212,7 @@
"ZXp0z1": "Features", "ZXp0z1": "Features",
"ZaNcK4": "No goals yet", "ZaNcK4": "No goals yet",
"ZcgtZo": "We've put together a list of easy-to-use exchanges that will allow you to buy a small amount of bitcoin (sats) and to transfer them to your wallet of choice.", "ZcgtZo": "We've put together a list of easy-to-use exchanges that will allow you to buy a small amount of bitcoin (sats) and to transfer them to your wallet of choice.",
"Zfr//4": "No duration provided, please try another upload server.",
"ZmqxZs": "You can change this later", "ZmqxZs": "You can change this later",
"ZsYhvh": "Zaps are lightning payments, which are published on nostr as receipts.", "ZsYhvh": "Zaps are lightning payments, which are published on nostr as receipts.",
"Zse7yG": "Raid target", "Zse7yG": "Raid target",
@ -232,6 +234,7 @@
"dOQCL8": "Display name", "dOQCL8": "Display name",
"dVD/AR": "Top Zappers", "dVD/AR": "Top Zappers",
"dkUMIH": "Clip by {name}", "dkUMIH": "Clip by {name}",
"dqGkI+": "Video durations vary too much, are you sure each variant is the same video?",
"e011kf": "FAQ", "e011kf": "FAQ",
"ebmhes": "Nostr Extension", "ebmhes": "Nostr Extension",
"f6biFA": "Oh, and you have {n} sats of free streaming on us! 💜", "f6biFA": "Oh, and you have {n} sats of free streaming on us! 💜",
@ -311,6 +314,7 @@
"ug01Mk": "Time", "ug01Mk": "Time",
"uksRSi": "Latest Videos", "uksRSi": "Latest Videos",
"vP4dFa": "Visit {link} to get some sweet zap.stream merch!", "vP4dFa": "Visit {link} to get some sweet zap.stream merch!",
"vaZKTn": "Add more content",
"vrTOHJ": "{amount} sats", "vrTOHJ": "{amount} sats",
"w+2Vw7": "Shorts", "w+2Vw7": "Shorts",
"w0Xm2F": "Start typing", "w0Xm2F": "Start typing",

View File

@ -2573,14 +2573,14 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@snort/system-react@npm:^1.5.6": "@snort/system-react@npm:^1.6.1":
version: 1.5.6 version: 1.6.1
resolution: "@snort/system-react@npm:1.5.6" resolution: "@snort/system-react@npm:1.6.1"
dependencies: dependencies:
"@snort/shared": "npm:^1.0.17" "@snort/shared": "npm:^1.0.17"
"@snort/system": "npm:^1.5.6" "@snort/system": "npm:^1.6.1"
react: "npm:^18.2.0" react: "npm:^18.2.0"
checksum: 10c0/b7fb8dbc87328603b202dad9ce57dbff2cb8231829128b48346ba10925ee9fa5103652941b6a0456d6221fce8379a7148dc6507ebe8e1d0d35f7efd92738d08a checksum: 10c0/fe3180c1f4341df4fd585c4031ef632a7d59c7637c6aed16ea254a1c22a66e288516d96939c0080efd0fb73940531855a59a24fae5f82c326f6544362eb0394c
languageName: node languageName: node
linkType: hard linkType: hard
@ -2591,9 +2591,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@snort/system@npm:^1.5.6": "@snort/system@npm:^1.6.1":
version: 1.5.6 version: 1.6.1
resolution: "@snort/system@npm:1.5.6" resolution: "@snort/system@npm:1.6.1"
dependencies: dependencies:
"@noble/ciphers": "npm:^0.6.0" "@noble/ciphers": "npm:^0.6.0"
"@noble/curves": "npm:^1.4.0" "@noble/curves": "npm:^1.4.0"
@ -2608,33 +2608,33 @@ __metadata:
nostr-social-graph: "npm:^1.0.3" nostr-social-graph: "npm:^1.0.3"
uuid: "npm:^9.0.0" uuid: "npm:^9.0.0"
ws: "npm:^8.14.0" ws: "npm:^8.14.0"
checksum: 10c0/38fee2d55240f91a5e6ea0684a4bd94e6f4c56fab9b6a20d9ebef26dcdd17ed6c9a42bf1804c7bcf135a89c6882659baecede6d885dcead4a167d5e3337c9764 checksum: 10c0/5da01450970f4a51df98369c4cdd2b8886add3480e7962d11ed17498cf7db421bcd5e585abe532ff754efc0bf26b5734b9b24005f958802357ec390ccb74d281
languageName: node languageName: node
linkType: hard linkType: hard
"@snort/wallet@npm:^0.2.1": "@snort/wallet@npm:^0.2.4":
version: 0.2.1 version: 0.2.4
resolution: "@snort/wallet@npm:0.2.1" resolution: "@snort/wallet@npm:0.2.4"
dependencies: dependencies:
"@cashu/cashu-ts": "npm:^1.0.0-rc.3" "@cashu/cashu-ts": "npm:^1.0.0-rc.3"
"@lightninglabs/lnc-web": "npm:^0.3.1-alpha" "@lightninglabs/lnc-web": "npm:^0.3.1-alpha"
"@scure/base": "npm:^1.1.6" "@scure/base": "npm:^1.1.6"
"@snort/shared": "npm:^1.0.17" "@snort/shared": "npm:^1.0.17"
"@snort/system": "npm:^1.5.6" "@snort/system": "npm:^1.6.1"
debug: "npm:^4.3.4" debug: "npm:^4.3.4"
eventemitter3: "npm:^5.0.1" eventemitter3: "npm:^5.0.1"
checksum: 10c0/0dcf4b0336029e336bd6abcd7b79cf60d6fed08b2ab2847a8e791bb2399646e86c95aefc4dcfea08365c3dea417c362cdfa93939d7c36f037762de601936b331 checksum: 10c0/7d3e23d1d79595ee99e041b816cec5e7bfc6f34bfce6f0864a556de82cfcf29200f6978744b0717b5f6873c5c498e2dd9005571171f1f533f235cfbadf205a44
languageName: node languageName: node
linkType: hard linkType: hard
"@snort/worker-relay@npm:^1.3.0": "@snort/worker-relay@npm:^1.3.1":
version: 1.3.0 version: 1.3.1
resolution: "@snort/worker-relay@npm:1.3.0" resolution: "@snort/worker-relay@npm:1.3.1"
dependencies: dependencies:
"@sqlite.org/sqlite-wasm": "npm:^3.46.1-build3" "@sqlite.org/sqlite-wasm": "npm:^3.46.1-build3"
eventemitter3: "npm:^5.0.1" eventemitter3: "npm:^5.0.1"
uuid: "npm:^9.0.1" uuid: "npm:^9.0.1"
checksum: 10c0/1a0eb175f50787bbcaa585641bf710347b59f3d3426cbf0f83182a5574bf7a63beb3e5d66bb41506e2d50c3ee904d55670c85c7f1542018936dd5a4ce06726e8 checksum: 10c0/19b89e4f96df425d2d73e87fda1f82844bf7f3a1ba114073d0bf4052c9d5fe3eac9e6ca6d88ad8e36b65bae6dfcf69db5cb47828ef1c195419a94bd87ae2ff53
languageName: node languageName: node
linkType: hard linkType: hard
@ -7354,11 +7354,11 @@ __metadata:
"@noble/hashes": "npm:^1.4.0" "@noble/hashes": "npm:^1.4.0"
"@scure/base": "npm:^1.1.6" "@scure/base": "npm:^1.1.6"
"@snort/shared": "npm:^1.0.17" "@snort/shared": "npm:^1.0.17"
"@snort/system": "npm:^1.5.6" "@snort/system": "npm:^1.6.1"
"@snort/system-react": "npm:^1.5.6" "@snort/system-react": "npm:^1.6.1"
"@snort/system-wasm": "npm:^1.0.5" "@snort/system-wasm": "npm:^1.0.5"
"@snort/wallet": "npm:^0.2.1" "@snort/wallet": "npm:^0.2.4"
"@snort/worker-relay": "npm:^1.3.0" "@snort/worker-relay": "npm:^1.3.1"
"@szhsin/react-menu": "npm:^4.1.0" "@szhsin/react-menu": "npm:^4.1.0"
"@testing-library/dom": "npm:^9.3.1" "@testing-library/dom": "npm:^9.3.1"
"@types/node": "npm:^20.12.12" "@types/node": "npm:^20.12.12"