update for nip-35 spec

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"
},
"dependencies": {
"@noble/hashes": "^1.3.2",
"@snort/shared": "^1.0.14",
"@snort/system": "^1.2.12",
"@snort/system-react": "^1.2.12",
"@noble/hashes": "^1.4.0",
"@snort/shared": "^1.0.15",
"@snort/system": "^1.3.2",
"@snort/system-react": "^1.3.2",
"@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",
"react-router-dom": "^6.20.0"
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1"
},
"devDependencies": {
"@types/react": "^18.2.37",
@ -34,10 +34,10 @@
"eslint-plugin-react-refresh": "^0.4.4",
"postcss": "^8.4.31",
"prettier": "^3.1.0",
"tailwindcss": "^3.3.5",
"typescript": "^5.2.2",
"vite": "^5.1.6",
"vite-plugin-pwa": "^0.19.4"
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"vite": "^5.2.11",
"vite-plugin-pwa": "^0.20.0"
},
"packageManager": "yarn@4.1.1",
"prettier": {

View File

@ -1,7 +1,7 @@
import classNames from "classnames";
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;
type: "primary" | "secondary" | "danger";
small?: boolean;
@ -34,7 +34,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) =>
{...props}
type="button"
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",
colorScheme,
props.className,

View File

@ -33,3 +33,16 @@ a:not([href="/"], :has(button)) {
.text {
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 { NostrEvent, NotSignedNostrEvent } from "@snort/system";
import { EventExt, NostrEvent, NotSignedNostrEvent } from "@snort/system";
import { Trackers } from "./const";
export interface TorrentFile {
@ -8,13 +8,13 @@ export interface TorrentFile {
}
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;
}
export class NostrTorrent {
constructor(
readonly id: string,
readonly id: string | undefined,
readonly title: string,
readonly summary: string,
readonly infoHash: string,
@ -61,7 +61,7 @@ export class NostrTorrent {
return tcat.split(",");
} else {
// 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);
}
}
@ -96,6 +96,35 @@ export class NostrTorrent {
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
*/
@ -108,7 +137,7 @@ export class NostrTorrent {
pubkey: pubkey ?? "",
tags: [
["title", this.title],
["i", this.infoHash],
["x", this.infoHash],
],
} as NotSignedNostrEvent;
@ -118,10 +147,16 @@ export class NostrTorrent {
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}`]);
for (const tag of this.tags.filter((a) => a.type === "generic")) {
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;
}
@ -147,7 +182,7 @@ export class NostrTorrent {
}
// v0: btih tag
case "btih":
case "i": {
case "x": {
infoHash = t[1];
break;
}
@ -162,14 +197,9 @@ export class NostrTorrent {
trackers.push(t[1]);
break;
}
case "t": {
case "i": {
const kSplit = t[1].split(":", 2);
if (kSplit.length === 1) {
tags.push({
type: undefined,
value: t[1],
});
} else {
if (kSplit.length === 2) {
tags.push({
type: kSplit[0],
value: kSplit[1],
@ -177,8 +207,15 @@ export class NostrTorrent {
}
break;
}
case "t": {
tags.push({
type: "generic",
value: t[1],
} as TorrentTag);
break;
}
// v0: imdb tag
case "imdb": {
// v0: imdb tag
tags.push({
type: "imdb",
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 {
background-color: rgba(0, 0, 0, 0.5);
padding: 4px 10px;

View File

@ -1,14 +1,15 @@
import "./new.css";
import { ReactNode, useState } from "react";
import { Categories, Category, TorrentKind } from "../const";
import { Categories, Category } from "../const";
import { Button } from "../element/button";
import { useLogin } from "../login";
import { dedupe } from "@snort/shared";
import { dedupe, unixNow } from "@snort/shared";
import * as bencode from "../bencode";
import { sha1 } from "@noble/hashes/sha1";
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 { NostrTorrent, TorrentTag } from "../nostr-torrent";
async function openFile(): Promise<File | undefined> {
return new Promise((resolve) => {
@ -47,12 +48,13 @@ type TorrentEntry = {
name: string;
desc: string;
btih: string;
tags: Array<string>;
tcat: string;
files: Array<{
name: string;
size: number;
}>;
trackers: Array<string>;
externalLabels: Array<TorrentTag>;
};
function entryIsValid(entry: TorrentEntry) {
@ -60,7 +62,7 @@ function entryIsValid(entry: TorrentEntry) {
entry.name &&
entry.btih &&
entry.files.length > 0 &&
entry.tags.length > 0 &&
entry.tcat.length > 0 &&
entry.files.every((f) => f.name.length > 0)
);
}
@ -68,14 +70,18 @@ function entryIsValid(entry: TorrentEntry) {
export function NewPage() {
const login = useLogin();
const navigate = useNavigate();
const [newLabelType, setNewLabelType] = useState<TorrentTag["type"]>("imdb");
const [newLabelSubType, setNewLabelSubType] = useState("");
const [newLabelValue, setNewLabelValue] = useState("");
const [obj, setObj] = useState<TorrentEntry>({
name: "",
desc: "",
btih: "",
tags: [],
tcat: "",
files: [],
trackers: [],
externalLabels: [],
});
async function loadTorrent() {
@ -91,61 +97,72 @@ export function NewPage() {
length: number;
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({
name: dec.decode(info.name),
desc: dec.decode(torrent["comment"] as Uint8Array | undefined) ?? "",
btih: bytesToHex(sha1(infoBuf)),
tags: [],
tcat: "",
files: (info.files ?? [{ length: info.length, path: [info.name] }]).map((a) => ({
size: a.length,
name: a.path.map((b) => dec.decode(b)).join("/"),
})),
trackers: [],
trackers: dedupe([annouce, ...(announceList ?? [])]),
externalLabels: [],
});
}
}
async function publish() {
if (!login) return;
const ev = await login.builder.generic((eb) => {
const v = eb
.kind(TorrentKind)
.content(obj.desc)
.tag(["title", obj.name])
.tag(["btih", obj.btih])
.tag(["alt", `${obj.name}\nTorrent published on https://dtan.xyz`]);
obj.tags.forEach((t) => v.tag(["t", t]));
obj.files.forEach((f) => v.tag(["file", f.name, String(f.size)]));
return v;
});
const torrent = new NostrTorrent(
undefined,
obj.name,
obj.desc,
obj.btih,
unixNow(),
obj.files,
obj.trackers,
obj.externalLabels.concat([
{
type: "tcat",
value: obj.tcat,
},
]),
);
const ev = torrent.toEvent(login.publicKey);
ev.tags.push(["alt", `${obj.name}\nTorrent published on https://dtan.xyz`]);
console.debug(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 {
const tcat = tags.join(",");
return (
<>
<label className="category">
<input
type="radio"
value={tags.join(",")}
value={tcat}
name="category"
checked={obj.tags.join(",") === tags.join(",")}
checked={obj.tcat === tcat}
onChange={(e) =>
setObj((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>
{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 (
<>
<h2>New Torrent</h2>
@ -208,6 +261,136 @@ export function NewPage() {
</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">
<label className="text-indigo-300">
Files <span className="text-red-500">*</span>
@ -251,7 +434,7 @@ export function NewPage() {
/>
<Button
small
type="secondary"
type="danger"
onClick={() =>
setObj((o) => ({
...o,

View File

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

View File

@ -56,15 +56,24 @@ export function TorrentDetail({ item }: { item: TaggedNostrEvent }) {
<div className="flex items-center gap-2">
Tags:{" "}
<div className="flex gap-2">
{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>
))}
{torrent.tags.map((a, i) => {
if (a.type === "generic") {
return (
<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>
);
} 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>
{torrent.trackers.length > 0 && <div>Trackers: {torrent.trackers.length}</div>}
</div>
<div className="flex flex-col gap-2">
<Link to={torrent.magnetLink}>

1497
yarn.lock

File diff suppressed because it is too large Load Diff