mirror of
https://git.v0l.io/Kieran/dtan.git
synced 2024-12-12 15:06:22 +00:00
feat: NostrTorrent
This commit is contained in:
parent
0c715ea501
commit
1479093aef
0
.yarn/releases/yarn-4.1.1.cjs
vendored
Normal file → Executable file
0
.yarn/releases/yarn-4.1.1.cjs
vendored
Normal file → Executable file
@ -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",
|
||||
|
92
src/element/file-tree.tsx
Normal file
92
src/element/file-tree.tsx
Normal file
@ -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 (
|
||||
<>
|
||||
<div
|
||||
className="pl-1 flex gap-2 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
// lazy stateless toggle
|
||||
e.currentTarget.nextElementSibling?.classList.toggle("hidden");
|
||||
}}
|
||||
>
|
||||
<FolderIcon />
|
||||
{n.name}
|
||||
</div>
|
||||
<div className="pl-4 hidden">{n.children.sort((a) => (a.isDir ? -1 : 1)).map((b) => renderNode(b))}</div>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="pl-1 flex justify-between items-center" key={n.name}>
|
||||
<div className="flex gap-2">
|
||||
<FileIcon />
|
||||
{n.name}
|
||||
</div>
|
||||
<div>{FormatBytes(n.size)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="flex flex-col gap-1">{renderNode(tree)}</div>;
|
||||
}
|
13
src/element/icon/copy.tsx
Normal file
13
src/element/icon/copy.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
export default function CopyIcon() {
|
||||
return (
|
||||
<svg width={20} height={20} viewBox="0 0 20 20" fill="none">
|
||||
<path
|
||||
d="M13.3333 13.3327V15.666C13.3333 16.5994 13.3333 17.0661 13.1516 17.4227C12.9918 17.7363 12.7369 17.9912 12.4233 18.151C12.0668 18.3327 11.6 18.3327 10.6666 18.3327H4.33329C3.39987 18.3327 2.93316 18.3327 2.57664 18.151C2.26304 17.9912 2.00807 17.7363 1.84828 17.4227C1.66663 17.0661 1.66663 16.5994 1.66663 15.666V9.33268C1.66663 8.39926 1.66663 7.93255 1.84828 7.57603C2.00807 7.26243 2.26304 7.00746 2.57664 6.84767C2.93316 6.66602 3.39987 6.66602 4.33329 6.66602H6.66663M9.33329 13.3327H15.6666C16.6 13.3327 17.0668 13.3327 17.4233 13.151C17.7369 12.9912 17.9918 12.7363 18.1516 12.4227C18.3333 12.0661 18.3333 11.5994 18.3333 10.666V4.33268C18.3333 3.39926 18.3333 2.93255 18.1516 2.57603C17.9918 2.26243 17.7369 2.00746 17.4233 1.84767C17.0668 1.66602 16.6 1.66602 15.6666 1.66602H9.33329C8.39987 1.66602 7.93316 1.66602 7.57664 1.84767C7.26304 2.00746 7.00807 2.26243 6.84828 2.57603C6.66663 2.93255 6.66663 3.39926 6.66663 4.33268V10.666C6.66663 11.5994 6.66663 12.0661 6.84828 12.4227C7.00807 12.7363 7.26304 12.9912 7.57664 13.151C7.93316 13.3327 8.39987 13.3327 9.33329 13.3327Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
13
src/element/icon/file-icon.tsx
Normal file
13
src/element/icon/file-icon.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
export default function FileIcon() {
|
||||
return (
|
||||
<svg width={18} height={22} viewBox="0 0 18 22">
|
||||
<path
|
||||
d="M11 1.26946V5.4C11 5.96005 11 6.24008 11.109 6.45399C11.2049 6.64215 11.3578 6.79513 11.546 6.89101C11.7599 7 12.0399 7 12.6 7H16.7305M17 8.98822V16.2C17 17.8802 17 18.7202 16.673 19.362C16.3854 19.9265 15.9265 20.3854 15.362 20.673C14.7202 21 13.8802 21 12.2 21H5.8C4.11984 21 3.27976 21 2.63803 20.673C2.07354 20.3854 1.6146 19.9265 1.32698 19.362C1 18.7202 1 17.8802 1 16.2V5.8C1 4.11984 1 3.27976 1.32698 2.63803C1.6146 2.07354 2.07354 1.6146 2.63803 1.32698C3.27976 1 4.11984 1 5.8 1H9.01178C9.74555 1 10.1124 1 10.4577 1.08289C10.7638 1.15638 11.0564 1.27759 11.3249 1.44208C11.6276 1.6276 11.887 1.88703 12.4059 2.40589L15.5941 5.59411C16.113 6.11297 16.3724 6.3724 16.5579 6.67515C16.7224 6.94356 16.8436 7.2362 16.9171 7.5423C17 7.88757 17 8.25445 17 8.98822Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
13
src/element/icon/folder.tsx
Normal file
13
src/element/icon/folder.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
export default function FolderIcon() {
|
||||
return (
|
||||
<svg width={22} height={22} viewBox="0 0 22 20">
|
||||
<path
|
||||
d="M12 5L10.8845 2.76892C10.5634 2.1268 10.4029 1.80573 10.1634 1.57116C9.95158 1.36373 9.69632 1.20597 9.41607 1.10931C9.09916 1 8.74021 1 8.02229 1H4.2C3.0799 1 2.51984 1 2.09202 1.21799C1.71569 1.40973 1.40973 1.71569 1.21799 2.09202C1 2.51984 1 3.0799 1 4.2V5M1 5H16.2C17.8802 5 18.7202 5 19.362 5.32698C19.9265 5.6146 20.3854 6.07354 20.673 6.63803C21 7.27976 21 8.11984 21 9.8V14.2C21 15.8802 21 16.7202 20.673 17.362C20.3854 17.9265 19.9265 18.3854 19.362 18.673C18.7202 19 17.8802 19 16.2 19H5.8C4.11984 19 3.27976 19 2.63803 18.673C2.07354 18.3854 1.6146 17.9265 1.32698 17.362C1 16.7202 1 15.8802 1 14.2V5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
15
src/element/icon/magnet.tsx
Normal file
15
src/element/icon/magnet.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
export default function MagnetIcon({ size }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size ?? 20} height={size ?? 20} viewBox="0 0 64 64" fill="currentColor">
|
||||
<path
|
||||
d="M54.5,9.5c-4.9-5-11.4-7.8-18.3-7.8c-6.5,0-12.6,2.4-17.2,7L3.6,24.3c-2.5,2.5-2.5,6.6,0,9.1l5.9,5.9c2.5,2.5,6.6,2.5,9.1,0
|
||||
l14.5-14.4c1.8-1.8,4.6-2.1,6.3-0.7c0.9,0.7,1.4,1.8,1.5,3c0.1,1.4-0.5,2.7-1.5,3.7L24.9,45.4c-2.5,2.5-2.5,6.6,0,9.1l5.9,5.9
|
||||
c1.2,1.2,2.9,1.9,4.5,1.9c1.6,0,3.3-0.6,4.5-1.9l15.5-15.5C64.8,35.4,64.5,19.6,54.5,9.5z M15.4,36c-0.7,0.7-2,0.7-2.7,0l-5.9-5.9
|
||||
c-0.7-0.7-0.7-2,0-2.7l5.1-5.1l8.6,8.6L15.4,36z M36.6,57.2c-0.7,0.7-2,0.7-2.7,0L28,51.3c-0.7-0.7-0.7-2,0-2.7l5.1-5.1l8.6,8.6
|
||||
L36.6,57.2z M52.2,41.7L45,48.9l-8.6-8.6l6.3-6.3c1.9-1.9,2.9-4.5,2.8-7.1c-0.1-2.5-1.3-4.7-3.2-6.3c-1.6-1.3-3.5-1.9-5.5-1.9
|
||||
c-2.5,0-5,1-6.9,2.9l-6.1,6.1l-8.6-8.6l7.2-7.2c3.7-3.6,8.6-5.7,13.9-5.7c0,0,0.1,0,0.1,0c5.7,0,11,2.3,15.1,6.5
|
||||
C59.5,21,59.9,34,52.2,41.7z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
@ -1,47 +0,0 @@
|
||||
import { TaggedNostrEvent } from "@snort/system";
|
||||
import { Trackers } from "../const";
|
||||
import { Link, LinkProps } from "react-router-dom";
|
||||
|
||||
type MagnetLinkProps = Omit<LinkProps, "to"> & {
|
||||
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 (
|
||||
<Link {...props} to={link}>
|
||||
<svg width={size ?? 20} height={size ?? 20} version="1.1" viewBox="0 0 64 64" fill="currentColor">
|
||||
<path
|
||||
d="M54.5,9.5c-4.9-5-11.4-7.8-18.3-7.8c-6.5,0-12.6,2.4-17.2,7L3.6,24.3c-2.5,2.5-2.5,6.6,0,9.1l5.9,5.9c2.5,2.5,6.6,2.5,9.1,0
|
||||
l14.5-14.4c1.8-1.8,4.6-2.1,6.3-0.7c0.9,0.7,1.4,1.8,1.5,3c0.1,1.4-0.5,2.7-1.5,3.7L24.9,45.4c-2.5,2.5-2.5,6.6,0,9.1l5.9,5.9
|
||||
c1.2,1.2,2.9,1.9,4.5,1.9c1.6,0,3.3-0.6,4.5-1.9l15.5-15.5C64.8,35.4,64.5,19.6,54.5,9.5z M15.4,36c-0.7,0.7-2,0.7-2.7,0l-5.9-5.9
|
||||
c-0.7-0.7-0.7-2,0-2.7l5.1-5.1l8.6,8.6L15.4,36z M36.6,57.2c-0.7,0.7-2,0.7-2.7,0L28,51.3c-0.7-0.7-0.7-2,0-2.7l5.1-5.1l8.6,8.6
|
||||
L36.6,57.2z M52.2,41.7L45,48.9l-8.6-8.6l6.3-6.3c1.9-1.9,2.9-4.5,2.8-7.1c-0.1-2.5-1.3-4.7-3.2-6.3c-1.6-1.3-3.5-1.9-5.5-1.9
|
||||
c-2.5,0-5,1-6.9,2.9l-6.1,6.1l-8.6-8.6l7.2-7.2c3.7-3.6,8.6-5.7,13.9-5.7c0,0,0.1,0,0.1,0c5.7,0,11,2.3,15.1,6.5
|
||||
C59.5,21,59.9,34,52.2,41.7z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{props.children}
|
||||
</Link>
|
||||
);
|
||||
}
|
@ -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<TaggedNostrEvent> }) {
|
||||
return (
|
||||
@ -28,58 +29,44 @@ export function TorrentList({ items }: { items: Array<TaggedNostrEvent> }) {
|
||||
);
|
||||
}
|
||||
|
||||
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) => (
|
||||
<TagListEntry key={current[1]} tags={allTags} startIndex={index} tag={current} />
|
||||
));
|
||||
.map((current, index, allTags) => <TagListEntry key={current} tags={allTags} startIndex={index} tag={current} />);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Link to={`/search/?tags=${tagUrl}`}>{tag[1]}</Link>
|
||||
<Link to={`/search/?tags=${tagUrl}`}>{tag}</Link>
|
||||
{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 (
|
||||
<tr className="hover:bg-indigo-800">
|
||||
<td className="text-indigo-300">
|
||||
<TagList tags={item.tags} />
|
||||
<TagList torrent={torrent} />
|
||||
</td>
|
||||
<td className="break-words">
|
||||
<Link to={`/e/${NostrLink.fromEvent(item).encode()}`} state={item}>
|
||||
{name}
|
||||
{torrent.title}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="text-neutral-300">{new Date(item.created_at * 1000).toLocaleDateString()}</td>
|
||||
<td className="text-neutral-300">{new Date(torrent.publishedAt * 1000).toLocaleDateString()}</td>
|
||||
<td>
|
||||
<MagnetLink item={item} />
|
||||
<Link to={torrent.magnetLink}>
|
||||
<MagnetIcon />
|
||||
</Link>
|
||||
</td>
|
||||
<td className="whitespace-nowrap text-right text-neutral-300">{FormatBytes(size)}</td>
|
||||
<td className="whitespace-nowrap text-right text-neutral-300">{FormatBytes(torrent.totalSize)}</td>
|
||||
<td className="text-indigo-300 whitespace-nowrap break-words text-ellipsis">
|
||||
<Mention link={new NostrLink(NostrPrefix.PublicKey, item.pubkey)} />
|
||||
</td>
|
||||
|
@ -33,8 +33,3 @@ a:not([href="/"], :has(button)) {
|
||||
.text {
|
||||
white-space-collapse: preserve-breaks;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
193
src/nostr-torrent.ts
Normal file
193
src/nostr-torrent.ts
Normal file
@ -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<TorrentFile>,
|
||||
readonly trackers: Array<string>,
|
||||
readonly tags: Array<TorrentTag>,
|
||||
) {}
|
||||
|
||||
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<TorrentFile> = [];
|
||||
const trackers: Array<string> = [];
|
||||
const tags: Array<TorrentTag> = [];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
@ -47,11 +47,12 @@ type TorrentEntry = {
|
||||
name: string;
|
||||
desc: string;
|
||||
btih: string;
|
||||
tags: string[];
|
||||
tags: Array<string>;
|
||||
files: Array<{
|
||||
name: string;
|
||||
size: number;
|
||||
}>;
|
||||
trackers: Array<string>;
|
||||
};
|
||||
|
||||
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
|
||||
</Button>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-indigo-300">Trackers</label>
|
||||
{obj.trackers.map((a, i) => (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={a}
|
||||
className="flex-1 px-3 py-1 bg-neutral-800 rounded-xl focus-visible:outline-none"
|
||||
placeholder="udp://mytracker.net:3333"
|
||||
onChange={(e) =>
|
||||
setObj((o) => ({
|
||||
...o,
|
||||
trackers: o.trackers.map((f, ii) => {
|
||||
if (ii === i) {
|
||||
return e.target.value;
|
||||
}
|
||||
return f;
|
||||
}),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
small
|
||||
type="secondary"
|
||||
onClick={() =>
|
||||
setObj((o) => ({
|
||||
...o,
|
||||
trackers: o.trackers.filter((_, ii) => i !== ii),
|
||||
}))
|
||||
}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
type="secondary"
|
||||
onClick={() =>
|
||||
setObj((o) => ({
|
||||
...o,
|
||||
trackers: [...o.trackers, ""],
|
||||
}))
|
||||
}
|
||||
>
|
||||
Add Tracker
|
||||
</Button>
|
||||
<Button className="mt-4" type="primary" disabled={!entryIsValid(obj)} onClick={publish}>
|
||||
Publish
|
||||
</Button>
|
||||
|
@ -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 (
|
||||
<div className="flex flex-col gap-4 pb-8">
|
||||
<div className="flex gap-4 items-center text-xl">
|
||||
<ProfileImage pubkey={item.pubkey} />
|
||||
{name}
|
||||
</div>
|
||||
<div className=" bg-neutral-900 p-4 rounded-lg">
|
||||
<div className="text-2xl">{torrent.title}</div>
|
||||
<div className="flex flex-col gap-2 bg-neutral-900 p-4 rounded-lg">
|
||||
<ProfileImage pubkey={item.pubkey} withName={true} />
|
||||
<div className="flex flex-row">
|
||||
<div className="flex flex-col gap-2 flex-grow">
|
||||
<div>Size: {FormatBytes(size)}</div>
|
||||
<div>Uploaded: {new Date(item.created_at * 1000).toLocaleDateString()}</div>
|
||||
<div>Size: {FormatBytes(torrent.totalSize)}</div>
|
||||
<div>Uploaded: {new Date(torrent.publishedAt * 1000).toLocaleString()}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
Tags:{" "}
|
||||
<div className="flex gap-2">
|
||||
{tags.map((a, i) => (
|
||||
<div key={i} className="rounded-2xl py-1 px-4 bg-indigo-800 hover:bg-indigo-700">
|
||||
<Link to={`/search/?tags=${a}`}>#{a}</Link>
|
||||
</div>
|
||||
))}
|
||||
{torrent.tags
|
||||
.filter((a) => a.type === undefined)
|
||||
.map((a, i) => (
|
||||
<div key={i} className="rounded-2xl py-1 px-4 bg-indigo-800 hover:bg-indigo-700">
|
||||
<Link to={`/search/?tags=${a.value}`}>#{a.value}</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<MagnetLink
|
||||
item={item}
|
||||
className="flex gap-1 items-center px-4 py-3 rounded-full justify-center bg-indigo-800 hover:bg-indigo-700"
|
||||
<Link to={torrent.magnetLink}>
|
||||
<Button type="primary" className="flex gap-1 items-center">
|
||||
<MagnetIcon />
|
||||
Get this torrent
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(JSON.stringify(item, undefined, 2));
|
||||
}}
|
||||
className="flex gap-1 items-center"
|
||||
>
|
||||
Get this torrent
|
||||
</MagnetLink>
|
||||
<CopyIcon />
|
||||
Copy JSON
|
||||
</Button>
|
||||
{item.pubkey == login?.publicKey && (
|
||||
<Button type="danger" onClick={deleteTorrent}>
|
||||
Delete
|
||||
@ -94,27 +100,8 @@ export function TorrentDetail({ item }: { item: TaggedNostrEvent }) {
|
||||
</>
|
||||
)}
|
||||
<h3 className="mt-2">Files</h3>
|
||||
<div className="file-list flex flex-col gap-1 bg-neutral-900 p-4 rounded-lg">
|
||||
<table className="w-max">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<b>Filename</b>
|
||||
</th>
|
||||
<th>
|
||||
<b>Size</b>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedFiles.map((a, i) => (
|
||||
<tr key={i}>
|
||||
<td className="pr-4">{a[1]}</td>
|
||||
<td className="text-neutral-500 font-semibold text-right text-sm">{FormatBytes(Number(a[2]))}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex flex-col gap-1 bg-neutral-900 p-4 rounded-lg">
|
||||
<TorrentFileList torrent={torrent} />
|
||||
</div>
|
||||
<h3 className="mt-2">Comments</h3>
|
||||
<Comments link={link} />
|
||||
|
@ -1,14 +1,51 @@
|
||||
import { NostrSystem } from "@snort/system";
|
||||
import { FlatReqFilter, NostrEvent, NostrSystem, Optimizer, PowMiner, ReqFilter } from "@snort/system";
|
||||
import {
|
||||
default as wasmInit,
|
||||
expand_filter,
|
||||
get_diff,
|
||||
flat_merge,
|
||||
compress,
|
||||
schnorr_verify_event,
|
||||
pow,
|
||||
} from "@snort/system-wasm";
|
||||
import { WorkerRelayInterface } from "@snort/worker-relay";
|
||||
import WorkerVite from "@snort/worker-relay/src/worker?worker";
|
||||
|
||||
import WasmPath from "@snort/system-wasm/pkg/system_wasm_bg.wasm?url";
|
||||
|
||||
const workerScript = import.meta.env.DEV
|
||||
? new URL("@snort/worker-relay/dist/esm/worker.mjs", import.meta.url)
|
||||
: new WorkerVite();
|
||||
const workerRelay = new WorkerRelayInterface(workerScript);
|
||||
|
||||
export const WasmOptimizer = {
|
||||
expandFilter: (f: ReqFilter) => {
|
||||
return expand_filter(f) as Array<FlatReqFilter>;
|
||||
},
|
||||
getDiff: (prev: Array<ReqFilter>, next: Array<ReqFilter>) => {
|
||||
return get_diff(prev, next) as Array<FlatReqFilter>;
|
||||
},
|
||||
flatMerge: (all: Array<FlatReqFilter>) => {
|
||||
return flat_merge(all) as Array<ReqFilter>;
|
||||
},
|
||||
compress: (all: Array<ReqFilter>) => {
|
||||
return compress(all) as Array<ReqFilter>;
|
||||
},
|
||||
schnorrVerify: (ev) => {
|
||||
return schnorr_verify_event(ev);
|
||||
},
|
||||
} as Optimizer;
|
||||
|
||||
export class WasmPowWorker implements PowMiner {
|
||||
minePow(ev: NostrEvent, target: number): Promise<NostrEvent> {
|
||||
const res = pow(ev, target);
|
||||
return Promise.resolve(res);
|
||||
}
|
||||
}
|
||||
|
||||
export const System = new NostrSystem({
|
||||
cachingRelay: workerRelay,
|
||||
optimizer: WasmOptimizer,
|
||||
});
|
||||
|
||||
let didInit = false;
|
||||
@ -16,9 +53,17 @@ export async function initSystem() {
|
||||
if (didInit) return;
|
||||
didInit = true;
|
||||
|
||||
await workerRelay.init("dtan.db");
|
||||
await System.Init();
|
||||
const tasks = [
|
||||
wasmInit(WasmPath),
|
||||
workerRelay.init({
|
||||
databasePath: "dtan.db",
|
||||
insertBatchSize: 100,
|
||||
}),
|
||||
System.Init(),
|
||||
];
|
||||
|
||||
for (const r of ["wss://nos.lol", "wss://relay.damus.io", "wss://relay.nostr.band"]) {
|
||||
await System.ConnectToRelay(r, { read: true, write: true });
|
||||
System.ConnectToRelay(r, { read: true, write: true });
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
|
18
yarn.lock
18
yarn.lock
@ -2097,6 +2097,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@snort/system-wasm@npm:^1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "@snort/system-wasm@npm:1.0.2"
|
||||
checksum: 10c0/0cd754f8fceefc37d064423f46d57cb925faee9060b602eeef7d93cc92bc534d78e02b5b15531d96ee91dfa40d64c4cac82054597e9f59668fcf5c08fc871d5c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@snort/system@npm:^1.2.12":
|
||||
version: 1.2.12
|
||||
resolution: "@snort/system@npm:1.2.12"
|
||||
@ -2117,14 +2124,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@snort/worker-relay@npm:^1.0.9":
|
||||
version: 1.0.9
|
||||
resolution: "@snort/worker-relay@npm:1.0.9"
|
||||
"@snort/worker-relay@npm:^1.0.10":
|
||||
version: 1.0.10
|
||||
resolution: "@snort/worker-relay@npm:1.0.10"
|
||||
dependencies:
|
||||
"@sqlite.org/sqlite-wasm": "npm:^3.45.1-build1"
|
||||
eventemitter3: "npm:^5.0.1"
|
||||
uuid: "npm:^9.0.1"
|
||||
checksum: 10c0/0b70755724100682321c8318214df6d4f2f12383ac6ba12fbda7a3b0b765b2091fea1ff9205a2ff73631d8186e69abe4cadf7cb1fe1d637b7e5ed3456246244a
|
||||
checksum: 10c0/7595163359bc09096f8e8e12f7ed903ac50d96d8fcc0064d6bd72180faf17abd368464fb3622306e0c4c1b05ec0c0e93fd1f1162e2594c313c7e4a9c547f6d9f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -3152,7 +3159,8 @@ __metadata:
|
||||
"@snort/shared": "npm:^1.0.14"
|
||||
"@snort/system": "npm:^1.2.12"
|
||||
"@snort/system-react": "npm:^1.2.12"
|
||||
"@snort/worker-relay": "npm:^1.0.9"
|
||||
"@snort/system-wasm": "npm:^1.0.2"
|
||||
"@snort/worker-relay": "npm:^1.0.10"
|
||||
"@types/react": "npm:^18.2.37"
|
||||
"@types/react-dom": "npm:^18.2.15"
|
||||
"@typescript-eslint/eslint-plugin": "npm:^6.10.0"
|
||||
|
Loading…
Reference in New Issue
Block a user