From 1479093aef5a5f81681a49e47ec89c78c032aff6 Mon Sep 17 00:00:00 2001 From: kieran Date: Thu, 18 Apr 2024 18:48:55 +0100 Subject: [PATCH] feat: NostrTorrent --- .yarn/releases/yarn-4.1.1.cjs | 0 package.json | 3 +- src/element/file-tree.tsx | 92 ++++++++++++++++ src/element/icon/copy.tsx | 13 +++ src/element/icon/file-icon.tsx | 13 +++ src/element/icon/folder.tsx | 13 +++ src/element/icon/magnet.tsx | 15 +++ src/element/magnet.tsx | 47 -------- src/element/torrent-list.tsx | 45 +++----- src/index.css | 5 - src/nostr-torrent.ts | 193 +++++++++++++++++++++++++++++++++ src/page/new.tsx | 52 ++++++++- src/page/torrent.tsx | 81 ++++++-------- src/system.ts | 53 ++++++++- yarn.lock | 18 ++- 15 files changed, 504 insertions(+), 139 deletions(-) mode change 100644 => 100755 .yarn/releases/yarn-4.1.1.cjs create mode 100644 src/element/file-tree.tsx create mode 100644 src/element/icon/copy.tsx create mode 100644 src/element/icon/file-icon.tsx create mode 100644 src/element/icon/folder.tsx create mode 100644 src/element/icon/magnet.tsx delete mode 100644 src/element/magnet.tsx create mode 100644 src/nostr-torrent.ts diff --git a/.yarn/releases/yarn-4.1.1.cjs b/.yarn/releases/yarn-4.1.1.cjs old mode 100644 new mode 100755 diff --git a/package.json b/package.json index 5dc7c8b..2d238eb 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "@snort/shared": "^1.0.14", "@snort/system": "^1.2.12", "@snort/system-react": "^1.2.12", - "@snort/worker-relay": "^1.0.9", + "@snort/system-wasm": "^1.0.2", + "@snort/worker-relay": "^1.0.10", "classnames": "^2.3.2", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/src/element/file-tree.tsx b/src/element/file-tree.tsx new file mode 100644 index 0000000..d865114 --- /dev/null +++ b/src/element/file-tree.tsx @@ -0,0 +1,92 @@ +import { useMemo } from "react"; +import { NostrTorrent } from "../nostr-torrent"; +import FolderIcon from "./icon/folder"; +import FileIcon from "./icon/file-icon"; +import { FormatBytes } from "../const"; + +interface NodeTree { + isDir: boolean; + name: string; + size: number; + children: NodeTree[]; +} + +export default function TorrentFileList({ torrent }: { torrent: NostrTorrent }) { + const tree = useMemo(() => { + const ret = { + isDir: true, + name: "/", + size: 0, + children: [], + } as NodeTree; + + function addAndRecurse(a: { paths: string[]; size: number }, atNode: NodeTree) { + if (a.paths.length > 1) { + const newdir = a.paths.shift()!; + let existingNode = atNode.children.find((a) => a.name === newdir); + if (!existingNode) { + existingNode = { + isDir: true, + name: newdir, + size: 0, + children: [], + }; + atNode.children.push(existingNode); + } + addAndRecurse(a, existingNode); + } else { + atNode.children.push({ + isDir: false, + name: a.paths[0], + size: a.size, + children: [], + }); + } + } + + const split = torrent.files + .map((a) => ({ + size: a.size, + paths: a.name.split("/"), + })) + .sort((a, b) => a.paths.length - b.paths.length); + + split.forEach((a) => addAndRecurse(a, ret)); + return ret; + }, [torrent]); + + function renderNode(n: NodeTree): React.ReactNode { + if (n.isDir && n.name === "/") { + // skip first node and just render children + return <>{n.children.sort((a) => (a.isDir ? -1 : 1)).map((b) => renderNode(b))}; + } else if (n.isDir) { + return ( + <> +
{ + // lazy stateless toggle + e.currentTarget.nextElementSibling?.classList.toggle("hidden"); + }} + > + + {n.name} +
+
{n.children.sort((a) => (a.isDir ? -1 : 1)).map((b) => renderNode(b))}
+ + ); + } else { + return ( +
+
+ + {n.name} +
+
{FormatBytes(n.size)}
+
+ ); + } + } + + return
{renderNode(tree)}
; +} diff --git a/src/element/icon/copy.tsx b/src/element/icon/copy.tsx new file mode 100644 index 0000000..71888c2 --- /dev/null +++ b/src/element/icon/copy.tsx @@ -0,0 +1,13 @@ +export default function CopyIcon() { + return ( + + + + ); +} diff --git a/src/element/icon/file-icon.tsx b/src/element/icon/file-icon.tsx new file mode 100644 index 0000000..4f6735e --- /dev/null +++ b/src/element/icon/file-icon.tsx @@ -0,0 +1,13 @@ +export default function FileIcon() { + return ( + + + + ); +} diff --git a/src/element/icon/folder.tsx b/src/element/icon/folder.tsx new file mode 100644 index 0000000..ef94e11 --- /dev/null +++ b/src/element/icon/folder.tsx @@ -0,0 +1,13 @@ +export default function FolderIcon() { + return ( + + + + ); +} diff --git a/src/element/icon/magnet.tsx b/src/element/icon/magnet.tsx new file mode 100644 index 0000000..c0c6236 --- /dev/null +++ b/src/element/icon/magnet.tsx @@ -0,0 +1,15 @@ +export default function MagnetIcon({ size }: { size?: number }) { + return ( + + + + ); +} diff --git a/src/element/magnet.tsx b/src/element/magnet.tsx deleted file mode 100644 index fa3ebb2..0000000 --- a/src/element/magnet.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { TaggedNostrEvent } from "@snort/system"; -import { Trackers } from "../const"; -import { Link, LinkProps } from "react-router-dom"; - -type MagnetLinkProps = Omit & { - item: TaggedNostrEvent; - size?: number; -}; - -export function MagnetLink({ item, size, ...props }: MagnetLinkProps) { - const btih = item.tags.find((a) => a[0] === "btih")?.at(1); - const name = item.tags.find((a) => a[0] === "title")?.at(1); - const magnet = { - xt: `urn:btih:${btih}`, - dn: name, - tr: Trackers, - }; - const params = Object.entries(magnet) - .map(([k, v]) => { - if (Array.isArray(v)) { - return v.map((a) => `${k}=${encodeURIComponent(a)}`).join("&"); - } else { - return `${k}=${v as string}`; - } - }) - .flat() - .join("&"); - const link = `magnet:?${params}`; - - return ( - - - - - - {props.children} - - ); -} diff --git a/src/element/torrent-list.tsx b/src/element/torrent-list.tsx index 2a78252..9a69f24 100644 --- a/src/element/torrent-list.tsx +++ b/src/element/torrent-list.tsx @@ -2,9 +2,10 @@ import "./torrent-list.css"; import { NostrLink, NostrPrefix, TaggedNostrEvent } from "@snort/system"; import { FormatBytes } from "../const"; import { Link } from "react-router-dom"; -import { MagnetLink } from "./magnet"; import { Mention } from "./mention"; import { useMemo } from "react"; +import { NostrTorrent } from "../nostr-torrent"; +import MagnetIcon from "./icon/magnet"; export function TorrentList({ items }: { items: Array }) { return ( @@ -28,58 +29,44 @@ export function TorrentList({ items }: { items: Array }) { ); } -function TagList({ tags }: { tags: string[][] }) { - return tags - .filter((a) => a[0] === "t") +function TagList({ torrent }: { torrent: NostrTorrent }) { + return torrent.categoryPath .slice(0, 3) - .map((current, index, allTags) => ( - - )); + .map((current, index, allTags) => ); } -function TagListEntry({ tags, startIndex, tag }: { tags: string[][]; startIndex: number; tag: string[] }) { +function TagListEntry({ tags, startIndex, tag }: { tags: string[]; startIndex: number; tag: string }) { const tagUrl = useMemo(() => { - return encodeURIComponent( - tags - .slice(0, startIndex + 1) - .map((b) => b[1]) - .join(","), - ); + return encodeURIComponent(tags.slice(0, startIndex + 1).join(",")); }, [tags, startIndex]); return ( <> - {tag[1]} + {tag} {tags.length !== startIndex + 1 && " > "} ); } function TorrentTableEntry({ item }: { item: TaggedNostrEvent }) { - const { name, size } = useMemo(() => { - const name = item.tags.find((a) => a[0] === "title")?.at(1); - const size = item.tags - .filter((a) => a[0] === "file") - .map((a) => Number(a[2])) - .reduce((acc, v) => (acc += v), 0); - return { name, size }; - }, [item]); - + const torrent = NostrTorrent.fromEvent(item); return ( - + - {name} + {torrent.title} - {new Date(item.created_at * 1000).toLocaleDateString()} + {new Date(torrent.publishedAt * 1000).toLocaleDateString()} - + + + - {FormatBytes(size)} + {FormatBytes(torrent.totalSize)} diff --git a/src/index.css b/src/index.css index 3ee2e5d..2ee62e8 100644 --- a/src/index.css +++ b/src/index.css @@ -33,8 +33,3 @@ a:not([href="/"], :has(button)) { .text { white-space-collapse: preserve-breaks; } - -.file-list { - font-size: 15px; - font-weight: 400; -} diff --git a/src/nostr-torrent.ts b/src/nostr-torrent.ts new file mode 100644 index 0000000..2a6dce4 --- /dev/null +++ b/src/nostr-torrent.ts @@ -0,0 +1,193 @@ +import { unixNow } from "@snort/shared"; +import { NostrEvent, NotSignedNostrEvent } from "@snort/system"; +import { Trackers } from "./const"; + +export interface TorrentFile { + readonly name: string; + readonly size: number; +} + +export interface TorrentTag { + readonly type: "tcat" | "newznab" | "tmdb" | "ttvdb" | "imdb" | "mal" | "anilist" | undefined; + readonly value: string; +} + +export class NostrTorrent { + constructor( + readonly id: string, + readonly title: string, + readonly summary: string, + readonly infoHash: string, + readonly publishedAt: number, + readonly files: Array, + readonly trackers: Array, + readonly tags: Array, + ) {} + + get newznab() { + return this.#getTagValue("newznab"); + } + + get imdb() { + return this.#getTagValue("imdb"); + } + + get tmdb() { + return this.#getTagValue("tmdb"); + } + + get ttvdb() { + return this.#getTagValue("ttvdb"); + } + + get mal() { + return this.#getTagValue("mal"); + } + + get anilist() { + return this.#getTagValue("anilist"); + } + + get totalSize() { + return this.files.reduce((acc, v) => acc + v.size, 0); + } + + /** + * Get the category path ie. video->movie->hd + */ + get categoryPath() { + const tcat = this.#getTagValue("tcat"); + if (tcat) { + return tcat.split(","); + } else { + // v0: ordered tags before tcat proposal + const regularTags = this.tags.filter((a) => a.type === undefined).slice(0, 3); + return regularTags.map((a) => a.value); + } + } + + get tcat() { + return this.categoryPath.join(","); + } + + get magnetLink() { + const magnet = { + xt: `urn:btih:${this.infoHash}`, + dn: this.title, + tr: this.trackers, + }; + + // use fallback tracker list if empty + if (magnet.tr.length === 0) { + magnet.tr.push(...Trackers); + } + + const params = Object.entries(magnet) + .map(([k, v]) => { + if (Array.isArray(v)) { + return v.map((a) => `${k}=${encodeURIComponent(a)}`).join("&"); + } else { + return `${k}=${v as string}`; + } + }) + .flat() + .filter((a) => a.length > 0) + .join("&"); + return `magnet:?${params}`; + } + + /** + * Get the nostr event for this torrent + */ + toEvent(pubkey?: string) { + const ret = { + id: this.id, + kind: 2003, + content: this.summary, + created_at: unixNow(), + pubkey: pubkey ?? "", + tags: [ + ["title", this.title], + ["i", this.infoHash], + ], + } as NotSignedNostrEvent; + + for (const file of this.files) { + ret.tags.push(["file", file.name, String(file.size)]); + } + for (const tracker of this.trackers) { + ret.tags.push(["tracker", tracker]); + } + for (const tag of this.tags) { + ret.tags.push(["t", `${tag.type !== undefined ? `${tag.type}:` : ""}${tag.value}`]); + } + + return ret; + } + + #getTagValue(t: TorrentTag["type"]) { + const tag = this.tags.find((a) => a.type === t); + return tag?.value; + } + + static fromEvent(ev: NostrEvent) { + let infoHash = ""; + let title = ""; + const files: Array = []; + const trackers: Array = []; + const tags: Array = []; + + for (const t of ev.tags) { + const key = t[0]; + if (!t[1]) continue; + switch (key) { + case "title": { + title = t[1]; + break; + } + // v0: btih tag + case "btih": + case "i": { + infoHash = t[1]; + break; + } + case "file": { + files.push({ + name: t[1], + size: Number(t[2]), + }); + break; + } + case "tracker": { + trackers.push(t[1]); + break; + } + case "t": { + const kSplit = t[1].split(":", 2); + if (kSplit.length === 1) { + tags.push({ + type: undefined, + value: t[1], + }); + } else { + tags.push({ + type: kSplit[0], + value: kSplit[1], + } as TorrentTag); + } + break; + } + case "imdb": { + // v0: imdb tag + tags.push({ + type: "imdb", + value: t[1], + }); + break; + } + } + } + + return new NostrTorrent(ev.id, title, ev.content, infoHash, ev.created_at, files, trackers, tags); + } +} diff --git a/src/page/new.tsx b/src/page/new.tsx index 329a4c1..3129151 100644 --- a/src/page/new.tsx +++ b/src/page/new.tsx @@ -47,11 +47,12 @@ type TorrentEntry = { name: string; desc: string; btih: string; - tags: string[]; + tags: Array; files: Array<{ name: string; size: number; }>; + trackers: Array; }; function entryIsValid(entry: TorrentEntry) { @@ -74,6 +75,7 @@ export function NewPage() { btih: "", tags: [], files: [], + trackers: [], }); async function loadTorrent() { @@ -99,6 +101,7 @@ export function NewPage() { size: a.length, name: a.path.map((b) => dec.decode(b)).join("/"), })), + trackers: [], }); } } @@ -272,6 +275,53 @@ export function NewPage() { > Add File +
+ + {obj.trackers.map((a, i) => ( +
+ + setObj((o) => ({ + ...o, + trackers: o.trackers.map((f, ii) => { + if (ii === i) { + return e.target.value; + } + return f; + }), + })) + } + /> + +
+ ))} +
+ diff --git a/src/page/torrent.tsx b/src/page/torrent.tsx index d309f6a..671e4f6 100644 --- a/src/page/torrent.tsx +++ b/src/page/torrent.tsx @@ -4,12 +4,14 @@ import { useRequestBuilder } from "@snort/system-react"; import { Link, useLocation, useNavigate, useParams } from "react-router-dom"; import { FormatBytes, TorrentKind } from "../const"; import { ProfileImage } from "../element/profile-image"; -import { MagnetLink } from "../element/magnet"; import { useLogin } from "../login"; import { Button } from "../element/button"; import { Comments } from "../element/comments"; -import { useMemo } from "react"; import { Text } from "../element/text"; +import { NostrTorrent } from "../nostr-torrent"; +import TorrentFileList from "../element/file-tree"; +import CopyIcon from "../element/icon/copy"; +import MagnetIcon from "../element/icon/magnet"; export function TorrentPage() { const location = useLocation(); @@ -32,13 +34,7 @@ export function TorrentDetail({ item }: { item: TaggedNostrEvent }) { const login = useLogin(); const navigate = useNavigate(); const link = NostrLink.fromEvent(item); - const name = item.tags.find((a) => a[0] === "title")?.at(1); - - const files = item.tags.filter((a) => a[0] === "file"); - const size = useMemo(() => files.map((a) => Number(a[2])).reduce((acc, v) => (acc += v), 0), [files]); - const sortedFiles = useMemo(() => files.sort((a, b) => (a[1] < b[1] ? -1 : 1)), [files]); - - const tags = item.tags.filter((a) => a[0] === "t").map((a) => a[1]); + const torrent = NostrTorrent.fromEvent(item); async function deleteTorrent() { const ev = await login?.builder?.delete(item.id); @@ -50,33 +46,43 @@ export function TorrentDetail({ item }: { item: TaggedNostrEvent }) { return (
-
- - {name} -
-
+
{torrent.title}
+
+
-
Size: {FormatBytes(size)}
-
Uploaded: {new Date(item.created_at * 1000).toLocaleDateString()}
+
Size: {FormatBytes(torrent.totalSize)}
+
Uploaded: {new Date(torrent.publishedAt * 1000).toLocaleString()}
Tags:{" "}
- {tags.map((a, i) => ( -
- #{a} -
- ))} + {torrent.tags + .filter((a) => a.type === undefined) + .map((a, i) => ( +
+ #{a.value} +
+ ))}
- + + + {item.pubkey == login?.publicKey && (