update for nip-35 spec
Some checks reported errors
continuous-integration/drone/push Build encountered an error

This commit is contained in:
kieran 2024-05-16 15:24:07 +01:00
parent 1479093aef
commit 85151ac008
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
9 changed files with 1336 additions and 548 deletions

View File

@ -11,16 +11,16 @@
"deploy": "yarn build && yarn dlx wrangler pages deploy dist" "deploy": "yarn build && yarn dlx wrangler pages deploy dist"
}, },
"dependencies": { "dependencies": {
"@noble/hashes": "^1.3.2", "@noble/hashes": "^1.4.0",
"@snort/shared": "^1.0.14", "@snort/shared": "^1.0.15",
"@snort/system": "^1.2.12", "@snort/system": "^1.3.2",
"@snort/system-react": "^1.2.12", "@snort/system-react": "^1.3.2",
"@snort/system-wasm": "^1.0.2", "@snort/system-wasm": "^1.0.2",
"@snort/worker-relay": "^1.0.10", "@snort/worker-relay": "^1.0.10",
"classnames": "^2.3.2", "classnames": "^2.3.2",
"react": "^18.2.0", "react": "^18.3.1",
"react-dom": "^18.2.0", "react-dom": "^18.3.1",
"react-router-dom": "^6.20.0" "react-router-dom": "^6.23.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.37", "@types/react": "^18.2.37",
@ -34,10 +34,10 @@
"eslint-plugin-react-refresh": "^0.4.4", "eslint-plugin-react-refresh": "^0.4.4",
"postcss": "^8.4.31", "postcss": "^8.4.31",
"prettier": "^3.1.0", "prettier": "^3.1.0",
"tailwindcss": "^3.3.5", "tailwindcss": "^3.4.3",
"typescript": "^5.2.2", "typescript": "^5.4.5",
"vite": "^5.1.6", "vite": "^5.2.11",
"vite-plugin-pwa": "^0.19.4" "vite-plugin-pwa": "^0.20.0"
}, },
"packageManager": "yarn@4.1.1", "packageManager": "yarn@4.1.1",
"prettier": { "prettier": {

View File

@ -1,7 +1,7 @@
import classNames from "classnames"; import classNames from "classnames";
import { HTMLProps, forwardRef, useState } from "react"; import { HTMLProps, forwardRef, useState } from "react";
type ButtonProps = Omit<HTMLProps<HTMLButtonElement>, "onClick"> & { type ButtonProps = Omit<HTMLProps<HTMLButtonElement>, "onClick" | "small"> & {
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => Promise<void> | void; onClick?: (e: React.MouseEvent<HTMLButtonElement>) => Promise<void> | void;
type: "primary" | "secondary" | "danger"; type: "primary" | "secondary" | "danger";
small?: boolean; small?: boolean;
@ -34,7 +34,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) =>
{...props} {...props}
type="button" type="button"
className={classNames( className={classNames(
props.small ? "px-3 py-1 rounded-2xl" : "px-4 py-3 rounded-full ", props.small ? "px-3 py-1 rounded-2xl" : "px-4 py-3 rounded-xl ",
"flex gap-1 items-center justify-center whitespace-nowrap", "flex gap-1 items-center justify-center whitespace-nowrap",
colorScheme, colorScheme,
props.className, props.className,

View File

@ -33,3 +33,16 @@ a:not([href="/"], :has(button)) {
.text { .text {
white-space-collapse: preserve-breaks; white-space-collapse: preserve-breaks;
} }
input[type="radio"] {
border: 0px;
clip: rect(0px, 0px, 0px, 0px);
height: 1px;
width: 1px;
margin: -1px;
padding: 0px;
overflow: hidden;
white-space: nowrap;
position: absolute;
cursor: pointer;
}

View File

@ -1,5 +1,5 @@
import { unixNow } from "@snort/shared"; import { unixNow } from "@snort/shared";
import { NostrEvent, NotSignedNostrEvent } from "@snort/system"; import { EventExt, NostrEvent, NotSignedNostrEvent } from "@snort/system";
import { Trackers } from "./const"; import { Trackers } from "./const";
export interface TorrentFile { export interface TorrentFile {
@ -8,13 +8,13 @@ export interface TorrentFile {
} }
export interface TorrentTag { export interface TorrentTag {
readonly type: "tcat" | "newznab" | "tmdb" | "ttvdb" | "imdb" | "mal" | "anilist" | undefined; readonly type: "tcat" | "newznab" | "tmdb" | "ttvdb" | "imdb" | "mal" | "anilist" | "generic";
readonly value: string; readonly value: string;
} }
export class NostrTorrent { export class NostrTorrent {
constructor( constructor(
readonly id: string, readonly id: string | undefined,
readonly title: string, readonly title: string,
readonly summary: string, readonly summary: string,
readonly infoHash: string, readonly infoHash: string,
@ -61,7 +61,7 @@ export class NostrTorrent {
return tcat.split(","); return tcat.split(",");
} else { } else {
// v0: ordered tags before tcat proposal // v0: ordered tags before tcat proposal
const regularTags = this.tags.filter((a) => a.type === undefined).slice(0, 3); const regularTags = this.tags.filter((a) => a.type === "generic").slice(0, 3);
return regularTags.map((a) => a.value); return regularTags.map((a) => a.value);
} }
} }
@ -96,6 +96,35 @@ export class NostrTorrent {
return `magnet:?${params}`; return `magnet:?${params}`;
} }
/**
* Get the URL for a non-generic external reference tag ("i" tag)
*/
static externalDbLink(tag: TorrentTag) {
const ts = tag.value.split(":");
switch (tag.type) {
case "imdb":
return `https://www.imdb.com/title/${tag.value}/`;
case "tmdb": {
if (ts.length === 2) {
return `https://www.themoviedb.org/${ts[0]}/${ts[1]}`;
}
break;
}
case "mal": {
if (ts.length === 2) {
return `https://myanimelist.net/${ts[0]}/${ts[1]}`;
}
break;
}
case "anilist": {
if (ts.length === 2) {
return `https://anilist.co/${ts[0]}/${ts[1]}`;
}
break;
}
}
}
/** /**
* Get the nostr event for this torrent * Get the nostr event for this torrent
*/ */
@ -108,7 +137,7 @@ export class NostrTorrent {
pubkey: pubkey ?? "", pubkey: pubkey ?? "",
tags: [ tags: [
["title", this.title], ["title", this.title],
["i", this.infoHash], ["x", this.infoHash],
], ],
} as NotSignedNostrEvent; } as NotSignedNostrEvent;
@ -118,10 +147,16 @@ export class NostrTorrent {
for (const tracker of this.trackers) { for (const tracker of this.trackers) {
ret.tags.push(["tracker", tracker]); ret.tags.push(["tracker", tracker]);
} }
for (const tag of this.tags) { for (const tag of this.tags.filter((a) => a.type === "generic")) {
ret.tags.push(["t", `${tag.type !== undefined ? `${tag.type}:` : ""}${tag.value}`]); ret.tags.push(["t", tag.value]);
}
for (const tag of this.tags.filter((a) => a.type !== "generic")) {
ret.tags.push(["i", `${tag.type}:${tag.value}`]);
} }
if (ret.id === undefined) {
ret.id = EventExt.createId(ret);
}
return ret; return ret;
} }
@ -147,7 +182,7 @@ export class NostrTorrent {
} }
// v0: btih tag // v0: btih tag
case "btih": case "btih":
case "i": { case "x": {
infoHash = t[1]; infoHash = t[1];
break; break;
} }
@ -162,14 +197,9 @@ export class NostrTorrent {
trackers.push(t[1]); trackers.push(t[1]);
break; break;
} }
case "t": { case "i": {
const kSplit = t[1].split(":", 2); const kSplit = t[1].split(":", 2);
if (kSplit.length === 1) { if (kSplit.length === 2) {
tags.push({
type: undefined,
value: t[1],
});
} else {
tags.push({ tags.push({
type: kSplit[0], type: kSplit[0],
value: kSplit[1], value: kSplit[1],
@ -177,8 +207,15 @@ export class NostrTorrent {
} }
break; break;
} }
case "t": {
tags.push({
type: "generic",
value: t[1],
} as TorrentTag);
break;
}
// v0: imdb tag
case "imdb": { case "imdb": {
// v0: imdb tag
tags.push({ tags.push({
type: "imdb", type: "imdb",
value: t[1], value: t[1],

View File

@ -1,16 +1,3 @@
label.category input {
border: 0px;
clip: rect(0px, 0px, 0px, 0px);
height: 1px;
width: 1px;
margin: -1px;
padding: 0px;
overflow: hidden;
white-space: nowrap;
position: absolute;
cursor: pointer;
}
label.category div { label.category div {
background-color: rgba(0, 0, 0, 0.5); background-color: rgba(0, 0, 0, 0.5);
padding: 4px 10px; padding: 4px 10px;

View File

@ -1,14 +1,15 @@
import "./new.css"; import "./new.css";
import { ReactNode, useState } from "react"; import { ReactNode, useState } from "react";
import { Categories, Category, TorrentKind } from "../const"; import { Categories, Category } from "../const";
import { Button } from "../element/button"; import { Button } from "../element/button";
import { useLogin } from "../login"; import { useLogin } from "../login";
import { dedupe } from "@snort/shared"; import { dedupe, unixNow } from "@snort/shared";
import * as bencode from "../bencode"; import * as bencode from "../bencode";
import { sha1 } from "@noble/hashes/sha1"; import { sha1 } from "@noble/hashes/sha1";
import { bytesToHex } from "@noble/hashes/utils"; import { bytesToHex } from "@noble/hashes/utils";
import { useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { NostrLink } from "@snort/system"; import { NostrLink } from "@snort/system";
import { NostrTorrent, TorrentTag } from "../nostr-torrent";
async function openFile(): Promise<File | undefined> { async function openFile(): Promise<File | undefined> {
return new Promise((resolve) => { return new Promise((resolve) => {
@ -47,12 +48,13 @@ type TorrentEntry = {
name: string; name: string;
desc: string; desc: string;
btih: string; btih: string;
tags: Array<string>; tcat: string;
files: Array<{ files: Array<{
name: string; name: string;
size: number; size: number;
}>; }>;
trackers: Array<string>; trackers: Array<string>;
externalLabels: Array<TorrentTag>;
}; };
function entryIsValid(entry: TorrentEntry) { function entryIsValid(entry: TorrentEntry) {
@ -60,7 +62,7 @@ function entryIsValid(entry: TorrentEntry) {
entry.name && entry.name &&
entry.btih && entry.btih &&
entry.files.length > 0 && entry.files.length > 0 &&
entry.tags.length > 0 && entry.tcat.length > 0 &&
entry.files.every((f) => f.name.length > 0) entry.files.every((f) => f.name.length > 0)
); );
} }
@ -68,14 +70,18 @@ function entryIsValid(entry: TorrentEntry) {
export function NewPage() { export function NewPage() {
const login = useLogin(); const login = useLogin();
const navigate = useNavigate(); const navigate = useNavigate();
const [newLabelType, setNewLabelType] = useState<TorrentTag["type"]>("imdb");
const [newLabelSubType, setNewLabelSubType] = useState("");
const [newLabelValue, setNewLabelValue] = useState("");
const [obj, setObj] = useState<TorrentEntry>({ const [obj, setObj] = useState<TorrentEntry>({
name: "", name: "",
desc: "", desc: "",
btih: "", btih: "",
tags: [], tcat: "",
files: [], files: [],
trackers: [], trackers: [],
externalLabels: [],
}); });
async function loadTorrent() { async function loadTorrent() {
@ -91,61 +97,72 @@ export function NewPage() {
length: number; length: number;
name: Uint8Array; name: Uint8Array;
}; };
const annouce = dec.decode(torrent["announce"] as Uint8Array | undefined);
const announceList = (torrent["announce-list"] as Array<Array<Uint8Array>> | undefined)?.map((a) =>
dec.decode(a[0]),
);
setObj({ setObj({
name: dec.decode(info.name), name: dec.decode(info.name),
desc: dec.decode(torrent["comment"] as Uint8Array | undefined) ?? "", desc: dec.decode(torrent["comment"] as Uint8Array | undefined) ?? "",
btih: bytesToHex(sha1(infoBuf)), btih: bytesToHex(sha1(infoBuf)),
tags: [], tcat: "",
files: (info.files ?? [{ length: info.length, path: [info.name] }]).map((a) => ({ files: (info.files ?? [{ length: info.length, path: [info.name] }]).map((a) => ({
size: a.length, size: a.length,
name: a.path.map((b) => dec.decode(b)).join("/"), name: a.path.map((b) => dec.decode(b)).join("/"),
})), })),
trackers: [], trackers: dedupe([annouce, ...(announceList ?? [])]),
externalLabels: [],
}); });
} }
} }
async function publish() { async function publish() {
if (!login) return; if (!login) return;
const ev = await login.builder.generic((eb) => { const torrent = new NostrTorrent(
const v = eb undefined,
.kind(TorrentKind) obj.name,
.content(obj.desc) obj.desc,
.tag(["title", obj.name]) obj.btih,
.tag(["btih", obj.btih]) unixNow(),
.tag(["alt", `${obj.name}\nTorrent published on https://dtan.xyz`]); obj.files,
obj.trackers,
obj.tags.forEach((t) => v.tag(["t", t])); obj.externalLabels.concat([
obj.files.forEach((f) => v.tag(["file", f.name, String(f.size)])); {
type: "tcat",
return v; value: obj.tcat,
}); },
]),
);
const ev = torrent.toEvent(login.publicKey);
ev.tags.push(["alt", `${obj.name}\nTorrent published on https://dtan.xyz`]);
console.debug(ev); console.debug(ev);
if (ev) { if (ev) {
await login.system.BroadcastEvent(ev); const evSigned = await login.builder.signer.sign(ev);
login.system.BroadcastEvent(evSigned);
navigate(`/e/${NostrLink.fromEvent(evSigned).encode()}`);
} }
navigate(`/e/${NostrLink.fromEvent(ev).encode()}`);
} }
function renderCategories(a: Category, tags: Array<string>): ReactNode { function renderCategories(a: Category, tags: Array<string>): ReactNode {
const tcat = tags.join(",");
return ( return (
<> <>
<label className="category"> <label className="category">
<input <input
type="radio" type="radio"
value={tags.join(",")} value={tcat}
name="category" name="category"
checked={obj.tags.join(",") === tags.join(",")} checked={obj.tcat === tcat}
onChange={(e) => onChange={(e) =>
setObj((o) => ({ setObj((o) => ({
...o, ...o,
tags: e.target.checked ? dedupe(e.target.value.split(",")) : [], tcat: e.target.value,
})) }))
} }
/> />
<div data-checked={obj.tags.join(",") === tags.join(",")}>{a?.name}</div> <div data-checked={obj.tcat === tcat}>{a?.name}</div>
</label> </label>
{a.sub_category?.map((b) => renderCategories(b, [...tags, b.tag]))} {a.sub_category?.map((b) => renderCategories(b, [...tags, b.tag]))}
@ -153,6 +170,42 @@ export function NewPage() {
); );
} }
function externalDbLogo(type: TorrentTag["type"]) {
switch (type) {
case "imdb":
return (
<img
className="h-8"
title="IMDB"
src="https://m.media-amazon.com/images/G/01/imdb/images-ANDW73HA/favicon_desktop_32x32._CB1582158068_.png"
/>
);
case "tmdb":
return (
<img
className="h-8"
title="TheMovieDatabase"
src="https://www.themoviedb.org/assets/2/favicon-32x32-543a21832c8931d3494a68881f6afcafc58e96c5d324345377f3197a37b367b5.png"
/>
);
case "ttvdb":
return <img className="h-8" title="TheTVDatabase" src="https://thetvdb.com/images/icon.png" />;
case "mal":
return (
<img
className="h-8"
title="MyAnimeList"
src="https://myanimelist.net/img/common/pwa/launcher-icon-0-75x.png"
/>
);
case "anilist":
return <img className="h-8" title="AniList" src="https://anilist.co/img/icons/favicon-32x32.png" />;
case "newznab":
return <img className="h-8" title="newznab" src="https://www.newznab.com/favicon.ico" />;
}
return <div className="border border-neutral-600 rounded-xl px-2">{type}</div>;
}
return ( return (
<> <>
<h2>New Torrent</h2> <h2>New Torrent</h2>
@ -208,6 +261,136 @@ export function NewPage() {
</div> </div>
</div> </div>
<div className="flex flex-col gap-2">
<label className="text-indigo-300">External Ids</label>
<div className="flex gap-2">
<select
value={newLabelType}
onChange={(e) => setNewLabelType(e.target.value as TorrentTag["type"])}
className="p-4 rounded-xl bg-neutral-800 focus-visible:outline-none"
>
<option value="imdb">IMDB</option>
<option value="newznab">newznab</option>
<option value="tmdb">TMDB (TheMovieDatabase)</option>
<option value="ttvdb">TTVDB (TheTVDatabase)</option>
<option value="mal">MAL (MyAnimeList)</option>
<option value="anilist">AniList</option>
</select>
{(() => {
switch (newLabelType) {
case "mal":
case "anilist": {
if (newLabelSubType !== "anime" && newLabelSubType !== "manga") {
setNewLabelSubType("anime");
}
return (
<select
className="p-4 rounded-xl bg-neutral-800 focus-visible:outline-none"
value={newLabelSubType}
onChange={(e) => setNewLabelSubType(e.target.value)}
>
<option value="anime">Anime</option>
<option value="manga">Manga</option>
</select>
);
}
case "tmdb": {
if (newLabelSubType !== "tv" && newLabelSubType !== "movie") {
setNewLabelSubType("tv");
}
return (
<select
className="p-4 rounded-xl bg-neutral-800 focus-visible:outline-none"
value={newLabelSubType}
onChange={(e) => setNewLabelSubType(e.target.value)}
>
<option value="tv">TV</option>
<option value="movie">Movie</option>
</select>
);
}
case "ttvdb": {
if (newLabelSubType !== "series" && newLabelSubType !== "movies") {
setNewLabelSubType("series");
}
return (
<select
className="p-4 rounded-xl bg-neutral-800 focus-visible:outline-none"
value={newLabelSubType}
onChange={(e) => setNewLabelSubType(e.target.value)}
>
<option value="series">Series</option>
<option value="movies">Movie</option>
</select>
);
}
default: {
if (newLabelSubType != "") {
setNewLabelSubType("");
}
}
}
})()}
<input
type="text"
className="p-4 rounded-xl bg-neutral-800 focus-visible:outline-none font-mono text-sm"
value={newLabelValue}
onChange={(e) => setNewLabelValue(e.target.value)}
/>
<Button
type="secondary"
onClick={() => {
const existing = obj.externalLabels.find((a) => a.type === newLabelType);
if (!existing) {
obj.externalLabels.push({
type: newLabelType as TorrentTag["type"],
value: `${newLabelSubType ? `${newLabelSubType}:` : ""}${newLabelValue}`,
});
setObj({ ...obj });
setNewLabelValue("");
}
}}
>
Add
</Button>
</div>
<div className="flex flex-col gap-2">
{obj.externalLabels.map((a) => {
const link = NostrTorrent.externalDbLink(a);
return (
<div className="flex justify-between bg-neutral-800 px-3 py-1 rounded-xl">
<div className="flex gap-2 items-center">
{externalDbLogo(a.type)}
{link && (
<>
<Link to={link} target="_blank" className="text-indigo-400 hover:underline">
{a.value}
</Link>
</>
)}
{!link && a.value}
</div>
<Button
type="danger"
small={true}
onClick={() =>
setObj((o) => {
const idx = o.externalLabels.findIndex((b) => b.type === a.type);
if (idx !== -1) {
o.externalLabels.splice(idx, 1);
}
return { ...o };
})
}
>
Remove
</Button>
</div>
);
})}
</div>
</div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="text-indigo-300"> <label className="text-indigo-300">
Files <span className="text-red-500">*</span> Files <span className="text-red-500">*</span>
@ -251,7 +434,7 @@ export function NewPage() {
/> />
<Button <Button
small small
type="secondary" type="danger"
onClick={() => onClick={() =>
setObj((o) => ({ setObj((o) => ({
...o, ...o,

View File

@ -10,6 +10,7 @@ export function SearchPage() {
const term = params.term as string | undefined; const term = params.term as string | undefined;
const q = new URLSearchParams(location.search ?? ""); const q = new URLSearchParams(location.search ?? "");
const tags = q.get("tags")?.split(",") ?? []; const tags = q.get("tags")?.split(",") ?? [];
const iz = q.getAll("i");
const rb = new RequestBuilder(`search:${term}+${tags.join(",")}`); const rb = new RequestBuilder(`search:${term}+${tags.join(",")}`);
const f = rb const f = rb
@ -21,6 +22,9 @@ export function SearchPage() {
if (tags.length > 0) { if (tags.length > 0) {
f.tag("t", tags); f.tag("t", tags);
} }
if (iz.length > 0) {
f.tag("i", iz);
}
const data = useRequestBuilder(rb); const data = useRequestBuilder(rb);

View File

@ -56,15 +56,24 @@ export function TorrentDetail({ item }: { item: TaggedNostrEvent }) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
Tags:{" "} Tags:{" "}
<div className="flex gap-2"> <div className="flex gap-2">
{torrent.tags {torrent.tags.map((a, i) => {
.filter((a) => a.type === undefined) if (a.type === "generic") {
.map((a, i) => ( return (
<div key={i} className="rounded-2xl py-1 px-4 bg-indigo-800 hover:bg-indigo-700"> <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> <Link to={`/search/?tags=${a.value}`}>#{a.value}</Link>
</div> </div>
))} );
} else {
return (
<div key={i} className="rounded-2xl py-1 px-4 bg-indigo-800 hover:bg-indigo-700">
<Link to={`/search/?i=${a.type}:${a.value}`}>#{a.value}</Link>
</div>
);
}
})}
</div> </div>
</div> </div>
{torrent.trackers.length > 0 && <div>Trackers: {torrent.trackers.length}</div>}
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Link to={torrent.magnetLink}> <Link to={torrent.magnetLink}>

1497
yarn.lock

File diff suppressed because it is too large Load Diff