From 2e491e20c1f24c5d73c972b095f0bd1a6d07d882 Mon Sep 17 00:00:00 2001 From: enki Date: Sat, 16 May 2026 19:19:49 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=201=20=E2=80=94=20Nostr=20reader?= =?UTF-8?q?=20+=20Torznab=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reader (internal/nostr): - Connects to all configured relays via WebSocket - Subscribes to kind 2003 (NIP-35) events since (now - backfill_days) - Parses x/title/file/tracker/i/t tags into DB rows - Reconnects with exponential backoff (5s → 5min) on disconnect Torznab API (internal/torznab): - GET /api?t=caps — XML capabilities doc (exact shape Sonarr expects) - GET /api?t=search — FTS5 full-text search over title+description - All other t= types (tvsearch, movie, etc.) route to the same search handler - API key auth via ?apikey= query param; 401 XML error on missing/bad key - Magnet links built from info_hash + event tracker tags DB (internal/db/queries.go): - InsertTorrent with upsert + related rows (trackers, tags, files) - Search with FTS5 + optional category filter (parent cat expands to range) - GetAPIKey, CreateAPIKey, UpsertRelay, UpdateRelaySync/LastEvent CLI (cmd/kindexr-cli): - apikey create --label [--visibility all|wot|curated] Health endpoint now reports live relays_connected count. --- cmd/kindexr-cli/main.go | 79 +++++++- cmd/kindexr/main.go | 13 +- go.mod | 24 +++ go.sum | 160 ++++++++++++++++ internal/db/queries.go | 328 ++++++++++++++++++++++++++++++++ internal/nostr/parser.go | 130 +++++++++++++ internal/nostr/parser_test.go | 157 +++++++++++++++ internal/nostr/reader.go | 126 ++++++++++++ internal/server/server.go | 26 ++- internal/torznab/caps.go | 69 +++++++ internal/torznab/categories.go | 46 +++++ internal/torznab/search.go | 160 ++++++++++++++++ internal/torznab/server.go | 69 +++++++ internal/torznab/server_test.go | 170 +++++++++++++++++ internal/torznab/xml.go | 114 +++++++++++ 15 files changed, 1659 insertions(+), 12 deletions(-) create mode 100644 internal/db/queries.go create mode 100644 internal/nostr/parser.go create mode 100644 internal/nostr/parser_test.go create mode 100644 internal/nostr/reader.go create mode 100644 internal/torznab/caps.go create mode 100644 internal/torznab/categories.go create mode 100644 internal/torznab/search.go create mode 100644 internal/torznab/server.go create mode 100644 internal/torznab/server_test.go create mode 100644 internal/torznab/xml.go diff --git a/cmd/kindexr-cli/main.go b/cmd/kindexr-cli/main.go index 8282ba3..43a7212 100644 --- a/cmd/kindexr-cli/main.go +++ b/cmd/kindexr-cli/main.go @@ -1,7 +1,6 @@ // Command kindexr-cli is the admin CLI for kindexr. // // TODO (Phase 3): implement the following sub-commands: -// - apikey create --label --visibility all|wot|curated // - apikey list // - apikey revoke // - relay add @@ -13,11 +12,85 @@ package main import ( + "context" + "crypto/rand" + "encoding/hex" + "flag" "fmt" "os" + + "git.utn.lol/enki/kindexr/internal/config" + "git.utn.lol/enki/kindexr/internal/db" ) func main() { - fmt.Println("kindexr-cli: not yet implemented") - os.Exit(0) + if len(os.Args) < 3 { + fmt.Fprintln(os.Stderr, "usage: kindexr-cli [flags]") + fmt.Fprintln(os.Stderr, " kindexr-cli apikey create --label [--visibility all|wot|curated]") + os.Exit(1) + } + + switch os.Args[1] { + case "apikey": + runAPIKey(os.Args[2:]) + default: + fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1]) + os.Exit(1) + } +} + +func runAPIKey(args []string) { + if len(args) < 1 { + fmt.Fprintln(os.Stderr, "usage: kindexr-cli apikey create --label ") + os.Exit(1) + } + switch args[0] { + case "create": + runAPIKeyCreate(args[1:]) + default: + fmt.Fprintf(os.Stderr, "unknown apikey subcommand: %s\n", args[0]) + os.Exit(1) + } +} + +func runAPIKeyCreate(args []string) { + fs := flag.NewFlagSet("apikey create", flag.ExitOnError) + configPath := fs.String("config", "/etc/kindexr/config.yaml", "path to config file") + label := fs.String("label", "", "key label (e.g. sonarr, radarr)") + visibility := fs.String("visibility", "all", "visibility: all|wot|curated") + if err := fs.Parse(args); err != nil { + os.Exit(1) + } + + if *label == "" { + fmt.Fprintln(os.Stderr, "error: --label is required") + os.Exit(1) + } + + cfg, err := config.Load(*configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "error loading config: %v\n", err) + os.Exit(1) + } + + database, err := db.Open(cfg.Database.Path) + if err != nil { + fmt.Fprintf(os.Stderr, "error opening database: %v\n", err) + os.Exit(1) + } + defer database.Close() + + keyBytes := make([]byte, 32) + if _, err := rand.Read(keyBytes); err != nil { + fmt.Fprintf(os.Stderr, "error generating key: %v\n", err) + os.Exit(1) + } + key := hex.EncodeToString(keyBytes) + + if err := database.CreateAPIKey(context.Background(), key, *label, *visibility); err != nil { + fmt.Fprintf(os.Stderr, "error creating api key: %v\n", err) + os.Exit(1) + } + + fmt.Println(key) } diff --git a/cmd/kindexr/main.go b/cmd/kindexr/main.go index 18c3aa3..f4eb153 100644 --- a/cmd/kindexr/main.go +++ b/cmd/kindexr/main.go @@ -13,6 +13,7 @@ import ( "git.utn.lol/enki/kindexr/internal/config" "git.utn.lol/enki/kindexr/internal/db" + nostrreader "git.utn.lol/enki/kindexr/internal/nostr" "git.utn.lol/enki/kindexr/internal/server" ) @@ -68,8 +69,16 @@ func main() { } slog.Info("database ready", "path", cfg.Database.Path, "migration_version", migrationVer) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Start Nostr relay reader. + reader := nostrreader.New(cfg, database) + reader.Start(ctx) + // Create HTTP server. srv := server.New(cfg, database, version) + srv.SetConnectedRelays(reader.ConnectedCount) httpServer := &http.Server{ Addr: cfg.Server.Listen, Handler: srv.Handler(), @@ -80,10 +89,6 @@ func main() { slog.Info("starting kindexr", "version", version, "listen", cfg.Server.Listen) - // Start listening in the background. - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go func() { if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { slog.Error("http server error", "error", err) diff --git a/go.mod b/go.mod index cb9f864..439981c 100644 --- a/go.mod +++ b/go.mod @@ -3,22 +3,46 @@ module git.utn.lol/enki/kindexr go 1.25.0 require ( + github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect + github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect + github.com/btcsuite/btcd/btcutil v1.1.5 // indirect + github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect + github.com/bytedance/sonic v1.13.1 // indirect + github.com/bytedance/sonic/loader v0.2.4 // indirect + github.com/cloudwego/base64x v0.1.5 // indirect + github.com/coder/websocket v1.8.12 // indirect + github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-chi/chi/v5 v5.2.5 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect github.com/knadh/koanf/parsers/yaml v1.1.0 // indirect github.com/knadh/koanf/providers/env v1.1.0 // indirect github.com/knadh/koanf/providers/file v1.2.1 // indirect github.com/knadh/koanf/v2 v2.3.4 // indirect + github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/nbd-wtf/go-nostr v0.52.3 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect go.yaml.in/yaml/v3 v3.0.3 // indirect + golang.org/x/arch v0.15.0 // indirect + golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect golang.org/x/sys v0.42.0 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index e2d204c..d69a085 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,89 @@ +github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg= +github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3/go.mod h1:we0YA5CsBbH5+/NUzC/AlMmxaDtWlXeNsqrwXjTzmzA= +github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= +github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= +github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= +github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= +github.com/btcsuite/btcd v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY= +github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA= +github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= +github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ= +github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= +github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A= +github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE= +github.com/btcsuite/btcd/btcutil v1.1.5 h1:+wER79R5670vs/ZusMTF1yTcRYE5GUsFbdjdisflzM8= +github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ= +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= +github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= +github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= +github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= +github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I= +github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= +github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/bytedance/sonic v1.13.1 h1:Jyd5CIvdFnkOWuKXr+wm4Nyk2h0yAFsr8ucJgEasO3g= +github.com/bytedance/sonic v1.13.1/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= +github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= +github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= +github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= +github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4= @@ -18,21 +94,104 @@ github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= github.com/knadh/koanf/v2 v2.3.4 h1:fnynNSDlujWE+v83hAp8wKr/cdoxHLO0629SN+U8Urc= github.com/knadh/koanf/v2 v2.3.4/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/nbd-wtf/go-nostr v0.52.3 h1:Xd87pXfJEJRXHpM+fLjQQln8dBNNaoPA10V7BbyP4KI= +github.com/nbd-wtf/go-nostr v0.52.3/go.mod h1:4avYoc9mDGZ9wHsvCOhHH9vPzKucCfuYBtJUSpHTfNk= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= +github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= +golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw= +golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE= +golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= +golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= @@ -41,3 +200,4 @@ modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w= modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/internal/db/queries.go b/internal/db/queries.go new file mode 100644 index 0000000..c9ebd8e --- /dev/null +++ b/internal/db/queries.go @@ -0,0 +1,328 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" +) + +// TorrentRecord holds all data for a single NIP-35 event to be stored. +type TorrentRecord struct { + EventID string + InfoHash string + Pubkey string + CreatedAt int64 + IngestedAt int64 + Title string + Description string + SizeBytes *int64 + Category string + NewznabCat *int + ImdbID string + TmdbID string + TvdbID string + Season *int + Episode *int + Quality string + Source string + RawEvent string + Trackers []string + Tags []string + Files []FileRecord +} + +// FileRecord represents a single file within a torrent. +type FileRecord struct { + Path string + SizeBytes *int64 +} + +// InsertTorrent upserts a torrent and its related rows in a single transaction. +// Duplicate event_id is silently ignored. +func (d *DB) InsertTorrent(ctx context.Context, r TorrentRecord) error { + tx, err := d.DB.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("insert torrent: begin: %w", err) + } + defer tx.Rollback() + + _, err = tx.ExecContext(ctx, ` + INSERT OR IGNORE INTO torrents + (event_id, info_hash, pubkey, created_at, ingested_at, title, description, + size_bytes, category, newznab_cat, imdb_id, tmdb_id, tvdb_id, + season, episode, quality, source, raw_event) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + r.EventID, r.InfoHash, r.Pubkey, r.CreatedAt, r.IngestedAt, + r.Title, r.Description, nullInt64(r.SizeBytes), r.Category, nullInt(r.NewznabCat), + nullStr(r.ImdbID), nullStr(r.TmdbID), nullStr(r.TvdbID), + nullInt(r.Season), nullInt(r.Episode), nullStr(r.Quality), nullStr(r.Source), + r.RawEvent, + ) + if err != nil { + return fmt.Errorf("insert torrent row: %w", err) + } + + // Check whether the row was actually inserted (INSERT OR IGNORE skips duplicates). + var changes int64 + if err := tx.QueryRowContext(ctx, `SELECT changes()`).Scan(&changes); err != nil { + return fmt.Errorf("check changes: %w", err) + } + if changes == 0 { + return tx.Commit() // duplicate, nothing more to do + } + + for _, url := range r.Trackers { + if _, err := tx.ExecContext(ctx, + `INSERT OR IGNORE INTO trackers (event_id, url) VALUES (?, ?)`, r.EventID, url); err != nil { + return fmt.Errorf("insert tracker: %w", err) + } + } + for _, tag := range r.Tags { + if _, err := tx.ExecContext(ctx, + `INSERT OR IGNORE INTO tags (event_id, tag) VALUES (?, ?)`, r.EventID, tag); err != nil { + return fmt.Errorf("insert tag: %w", err) + } + } + for i, f := range r.Files { + if _, err := tx.ExecContext(ctx, + `INSERT OR IGNORE INTO files (event_id, idx, path, size_bytes) VALUES (?, ?, ?, ?)`, + r.EventID, i, f.Path, nullInt64(f.SizeBytes)); err != nil { + return fmt.Errorf("insert file: %w", err) + } + } + + return tx.Commit() +} + +// TorrentRow is a single result row from Search. +type TorrentRow struct { + EventID string + InfoHash string + Pubkey string + CreatedAt int64 + Title string + Description string + SizeBytes *int64 + NewznabCat *int + ImdbID string + TmdbID string + TvdbID string + Season *int + Episode *int + Quality string + Source string +} + +// SearchParams controls a Search query. +type SearchParams struct { + Query string // FTS5 query; empty returns latest events + Cats []int // newznab category IDs; empty = all categories + Limit int // 0 defaults to 50; capped at 100 + Offset int +} + +// Search runs an FTS5 query (or a latest-events scan when Query is empty) +// and returns matching torrent rows. +func (d *DB) Search(ctx context.Context, p SearchParams) ([]TorrentRow, error) { + if p.Limit <= 0 { + p.Limit = 50 + } + if p.Limit > 100 { + p.Limit = 100 + } + + catWhere, catArgs := buildCatWhere(p.Cats) + + var query string + var args []interface{} + + if p.Query != "" { + query = `SELECT t.event_id, t.info_hash, t.pubkey, t.created_at, t.title, + t.description, t.size_bytes, t.newznab_cat, t.imdb_id, + t.tmdb_id, t.tvdb_id, t.season, t.episode, t.quality, t.source + FROM torrents_fts + JOIN torrents t ON torrents_fts.rowid = t.rowid + WHERE torrents_fts MATCH ?` + args = append(args, p.Query) + if catWhere != "" { + query += " AND (" + catWhere + ")" + args = append(args, catArgs...) + } + query += " ORDER BY torrents_fts.rank LIMIT ? OFFSET ?" + } else { + query = `SELECT event_id, info_hash, pubkey, created_at, title, + description, size_bytes, newznab_cat, imdb_id, + tmdb_id, tvdb_id, season, episode, quality, source + FROM torrents` + if catWhere != "" { + query += " WHERE " + catWhere + args = append(args, catArgs...) + } + query += " ORDER BY created_at DESC LIMIT ? OFFSET ?" + } + args = append(args, p.Limit, p.Offset) + + rows, err := d.DB.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("search: %w", err) + } + defer rows.Close() + + var results []TorrentRow + for rows.Next() { + var r TorrentRow + var sizeBytes sql.NullInt64 + var newznabCat sql.NullInt64 + var season, episode sql.NullInt64 + var imdbID, tmdbID, tvdbID, quality, source sql.NullString + if err := rows.Scan( + &r.EventID, &r.InfoHash, &r.Pubkey, &r.CreatedAt, &r.Title, + &r.Description, &sizeBytes, &newznabCat, &imdbID, + &tmdbID, &tvdbID, &season, &episode, &quality, &source, + ); err != nil { + return nil, fmt.Errorf("search scan: %w", err) + } + if sizeBytes.Valid { + v := sizeBytes.Int64 + r.SizeBytes = &v + } + if newznabCat.Valid { + v := int(newznabCat.Int64) + r.NewznabCat = &v + } + if season.Valid { + v := int(season.Int64) + r.Season = &v + } + if episode.Valid { + v := int(episode.Int64) + r.Episode = &v + } + r.ImdbID = imdbID.String + r.TmdbID = tmdbID.String + r.TvdbID = tvdbID.String + r.Quality = quality.String + r.Source = source.String + results = append(results, r) + } + return results, rows.Err() +} + +// GetTrackers returns all tracker URLs for the given event. +func (d *DB) GetTrackers(ctx context.Context, eventID string) ([]string, error) { + rows, err := d.DB.QueryContext(ctx, `SELECT url FROM trackers WHERE event_id = ?`, eventID) + if err != nil { + return nil, fmt.Errorf("get trackers: %w", err) + } + defer rows.Close() + var urls []string + for rows.Next() { + var u string + if err := rows.Scan(&u); err != nil { + return nil, err + } + urls = append(urls, u) + } + return urls, rows.Err() +} + +// APIKey holds a validated API key record. +type APIKey struct { + Key string + Label string + CreatedAt int64 + Visibility string +} + +// GetAPIKey returns the key record for the given raw key string, or nil if not found. +func (d *DB) GetAPIKey(ctx context.Context, key string) (*APIKey, error) { + var ak APIKey + err := d.DB.QueryRowContext(ctx, + `SELECT key, label, created_at, visibility FROM api_keys WHERE key = ?`, key, + ).Scan(&ak.Key, &ak.Label, &ak.CreatedAt, &ak.Visibility) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get api key: %w", err) + } + // Update last_used without blocking. + _, _ = d.DB.ExecContext(ctx, `UPDATE api_keys SET last_used = ? WHERE key = ?`, time.Now().Unix(), key) + return &ak, nil +} + +// CreateAPIKey inserts a new API key record. +func (d *DB) CreateAPIKey(ctx context.Context, key, label, visibility string) error { + _, err := d.DB.ExecContext(ctx, + `INSERT INTO api_keys (key, label, created_at, visibility) VALUES (?, ?, ?, ?)`, + key, label, time.Now().Unix(), visibility, + ) + if err != nil { + return fmt.Errorf("create api key: %w", err) + } + return nil +} + +// UpsertRelay ensures a relay URL exists in the relays table. +func (d *DB) UpsertRelay(ctx context.Context, url string) error { + _, err := d.DB.ExecContext(ctx, + `INSERT OR IGNORE INTO relays (url, enabled) VALUES (?, 1)`, url) + return err +} + +// UpdateRelaySync records the last time we successfully synced with a relay. +func (d *DB) UpdateRelaySync(ctx context.Context, url string, ts int64) error { + _, err := d.DB.ExecContext(ctx, + `UPDATE relays SET last_sync = ? WHERE url = ?`, ts, url) + return err +} + +// UpdateRelayLastEvent records the created_at of the most recent event from a relay. +func (d *DB) UpdateRelayLastEvent(ctx context.Context, url string, ts int64) error { + _, err := d.DB.ExecContext(ctx, + `UPDATE relays SET last_event = ? WHERE url = ?`, ts, url) + return err +} + +// buildCatWhere constructs the WHERE fragment and args for category filtering. +// Parent categories (multiples of 1000) match the full sub-range. +func buildCatWhere(cats []int) (string, []interface{}) { + if len(cats) == 0 { + return "", nil + } + var parts []string + var args []interface{} + for _, cat := range cats { + if cat%1000 == 0 { + parts = append(parts, "newznab_cat BETWEEN ? AND ?") + args = append(args, cat, cat+999) + } else { + parts = append(parts, "newznab_cat = ?") + args = append(args, cat) + } + } + return strings.Join(parts, " OR "), args +} + +func nullStr(s string) interface{} { + if s == "" { + return nil + } + return s +} + +func nullInt(p *int) interface{} { + if p == nil { + return nil + } + return *p +} + +func nullInt64(p *int64) interface{} { + if p == nil { + return nil + } + return *p +} diff --git a/internal/nostr/parser.go b/internal/nostr/parser.go new file mode 100644 index 0000000..6c6c1d7 --- /dev/null +++ b/internal/nostr/parser.go @@ -0,0 +1,130 @@ +package nostr + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "time" + + nostrlib "github.com/nbd-wtf/go-nostr" + + "git.utn.lol/enki/kindexr/internal/db" + "git.utn.lol/enki/kindexr/internal/torznab" +) + +var infoHashRe = regexp.MustCompile(`^[0-9a-fA-F]{40}$`) + +// Parse converts a kind 2003 Nostr event into a TorrentRecord ready for storage. +// Returns an error if the event is missing required fields. +func Parse(event *nostrlib.Event) (*db.TorrentRecord, error) { + if event.Kind != nostrlib.KindTorrent { + return nil, fmt.Errorf("wrong kind: %d", event.Kind) + } + + r := &db.TorrentRecord{ + EventID: event.ID, + Pubkey: event.PubKey, + CreatedAt: int64(event.CreatedAt), + IngestedAt: time.Now().Unix(), + RawEvent: event.String(), + } + + var totalSize int64 + hasTotalSize := false + + for _, tag := range event.Tags { + if len(tag) < 2 { + continue + } + switch tag[0] { + case "x": + hash := strings.ToLower(tag[1]) + if !infoHashRe.MatchString(hash) { + return nil, fmt.Errorf("invalid info_hash: %q", tag[1]) + } + r.InfoHash = hash + + case "title": + r.Title = tag[1] + + case "file": + path := tag[1] + rec := db.FileRecord{Path: path} + if len(tag) >= 3 { + if sz, err := strconv.ParseInt(tag[2], 10, 64); err == nil { + rec.SizeBytes = &sz + totalSize += sz + hasTotalSize = true + } + } + r.Files = append(r.Files, rec) + + case "tracker": + r.Trackers = append(r.Trackers, tag[1]) + + case "i": + parseITag(tag[1], r) + + case "t": + r.Tags = append(r.Tags, tag[1]) + } + } + + if r.InfoHash == "" { + return nil, fmt.Errorf("missing x (info_hash) tag") + } + + // Fall back to first line of content if no title tag. + if r.Title == "" { + if idx := strings.IndexByte(event.Content, '\n'); idx >= 0 { + r.Title = strings.TrimSpace(event.Content[:idx]) + } else { + r.Title = strings.TrimSpace(event.Content) + } + } + if r.Title == "" { + return nil, fmt.Errorf("event has no title") + } + + r.Description = event.Content + + if hasTotalSize { + r.SizeBytes = &totalSize + } + + return r, nil +} + +// parseITag parses a single value from an ["i", ""] tag and sets +// the appropriate fields on r. +func parseITag(value string, r *db.TorrentRecord) { + switch { + case strings.HasPrefix(value, "newznab:"): + if cat, err := strconv.Atoi(strings.TrimPrefix(value, "newznab:")); err == nil { + r.NewznabCat = &cat + } + + case strings.HasPrefix(value, "tcat:"): + // Only use tcat if newznab: wasn't already set. + if r.NewznabCat == nil { + cat := torznab.TcatToNewznab(strings.TrimPrefix(value, "tcat:")) + r.Category = strings.TrimPrefix(value, "tcat:") + r.NewznabCat = &cat + } else { + r.Category = strings.TrimPrefix(value, "tcat:") + } + + case strings.HasPrefix(value, "imdb:"): + r.ImdbID = strings.TrimPrefix(value, "imdb:") + + case strings.HasPrefix(value, "tmdb:movie:"): + r.TmdbID = "movie:" + strings.TrimPrefix(value, "tmdb:movie:") + + case strings.HasPrefix(value, "tmdb:tv:"): + r.TmdbID = "tv:" + strings.TrimPrefix(value, "tmdb:tv:") + + case strings.HasPrefix(value, "tvdb:"): + r.TvdbID = strings.TrimPrefix(value, "tvdb:") + } +} diff --git a/internal/nostr/parser_test.go b/internal/nostr/parser_test.go new file mode 100644 index 0000000..fe87bef --- /dev/null +++ b/internal/nostr/parser_test.go @@ -0,0 +1,157 @@ +package nostr_test + +import ( + "testing" + + nostrlib "github.com/nbd-wtf/go-nostr" + + "git.utn.lol/enki/kindexr/internal/nostr" +) + +func makeEvent(tags nostrlib.Tags, content string) *nostrlib.Event { + return &nostrlib.Event{ + ID: "aaaa111100000000000000000000000000000000000000000000000000000000", + Kind: nostrlib.KindTorrent, + PubKey: "bbbb222200000000000000000000000000000000000000000000000000000000", + CreatedAt: 1715000000, + Content: content, + Tags: tags, + } +} + +func TestParseBasic(t *testing.T) { + event := makeEvent(nostrlib.Tags{ + {"x", "abcdef1234abcdef1234abcdef1234abcdef1234"}, + {"title", "Some.Show.S01E02.1080p.WEB-DL.x265-GRP"}, + {"tracker", "udp://tracker.opentrackr.org:1337"}, + {"i", "newznab:5040"}, + {"t", "tv"}, + }, "a description") + + rec, err := nostr.Parse(event) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if rec.InfoHash != "abcdef1234abcdef1234abcdef1234abcdef1234" { + t.Errorf("wrong info_hash: %q", rec.InfoHash) + } + if rec.Title != "Some.Show.S01E02.1080p.WEB-DL.x265-GRP" { + t.Errorf("wrong title: %q", rec.Title) + } + if len(rec.Trackers) != 1 || rec.Trackers[0] != "udp://tracker.opentrackr.org:1337" { + t.Errorf("wrong trackers: %v", rec.Trackers) + } + if rec.NewznabCat == nil || *rec.NewznabCat != 5040 { + t.Errorf("wrong newznab_cat: %v", rec.NewznabCat) + } + if len(rec.Tags) != 1 || rec.Tags[0] != "tv" { + t.Errorf("wrong tags: %v", rec.Tags) + } +} + +func TestParseTitleFallback(t *testing.T) { + event := makeEvent(nostrlib.Tags{ + {"x", "abcdef1234abcdef1234abcdef1234abcdef1234"}, + }, "Title From Content\nmore description") + + rec, err := nostr.Parse(event) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if rec.Title != "Title From Content" { + t.Errorf("expected title from content first line, got %q", rec.Title) + } +} + +func TestParseMissingInfoHash(t *testing.T) { + event := makeEvent(nostrlib.Tags{ + {"title", "Some Torrent"}, + }, "") + + _, err := nostr.Parse(event) + if err == nil { + t.Fatal("expected error for missing info_hash") + } +} + +func TestParseInvalidInfoHash(t *testing.T) { + event := makeEvent(nostrlib.Tags{ + {"x", "not-a-hash"}, + {"title", "Some Torrent"}, + }, "") + + _, err := nostr.Parse(event) + if err == nil { + t.Fatal("expected error for invalid info_hash") + } +} + +func TestParseFileSizes(t *testing.T) { + event := makeEvent(nostrlib.Tags{ + {"x", "abcdef1234abcdef1234abcdef1234abcdef1234"}, + {"title", "My Torrent"}, + {"file", "file1.mkv", "1000000"}, + {"file", "file2.srt", "5000"}, + }, "") + + rec, err := nostr.Parse(event) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if rec.SizeBytes == nil { + t.Fatal("expected SizeBytes to be set") + } + if *rec.SizeBytes != 1005000 { + t.Errorf("expected SizeBytes=1005000, got %d", *rec.SizeBytes) + } + if len(rec.Files) != 2 { + t.Errorf("expected 2 files, got %d", len(rec.Files)) + } +} + +func TestParseITagIDs(t *testing.T) { + event := makeEvent(nostrlib.Tags{ + {"x", "abcdef1234abcdef1234abcdef1234abcdef1234"}, + {"title", "Breaking Bad"}, + {"i", "imdb:tt0903747"}, + {"i", "tmdb:tv:1396"}, + {"i", "tvdb:81189"}, + {"i", "tcat:video,tv"}, + }, "") + + rec, err := nostr.Parse(event) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if rec.ImdbID != "tt0903747" { + t.Errorf("wrong ImdbID: %q", rec.ImdbID) + } + if rec.TmdbID != "tv:1396" { + t.Errorf("wrong TmdbID: %q", rec.TmdbID) + } + if rec.TvdbID != "81189" { + t.Errorf("wrong TvdbID: %q", rec.TvdbID) + } + // tcat should set NewznabCat to 5000 (video,tv) + if rec.NewznabCat == nil || *rec.NewznabCat != 5000 { + t.Errorf("expected NewznabCat=5000, got %v", rec.NewznabCat) + } +} + +func TestParseNewznabOverridesTcat(t *testing.T) { + event := makeEvent(nostrlib.Tags{ + {"x", "abcdef1234abcdef1234abcdef1234abcdef1234"}, + {"title", "Some UHD Show"}, + {"i", "tcat:video,tv"}, + {"i", "newznab:5045"}, + }, "") + + rec, err := nostr.Parse(event) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + // newznab: tag takes precedence over tcat: + if rec.NewznabCat == nil || *rec.NewznabCat != 5045 { + t.Errorf("expected NewznabCat=5045, got %v", rec.NewznabCat) + } +} diff --git a/internal/nostr/reader.go b/internal/nostr/reader.go new file mode 100644 index 0000000..92ad73c --- /dev/null +++ b/internal/nostr/reader.go @@ -0,0 +1,126 @@ +package nostr + +import ( + "context" + "log/slog" + "sync/atomic" + "time" + + nostrlib "github.com/nbd-wtf/go-nostr" + + "git.utn.lol/enki/kindexr/internal/config" + "git.utn.lol/enki/kindexr/internal/db" +) + +// Reader subscribes to Nostr relays and indexes kind 2003 events. +type Reader struct { + cfg *config.Config + db *db.DB + connected atomic.Int32 +} + +// New creates a Reader. +func New(cfg *config.Config, database *db.DB) *Reader { + return &Reader{cfg: cfg, db: database} +} + +// ConnectedCount returns the number of currently connected relays. +func (rd *Reader) ConnectedCount() int { + return int(rd.connected.Load()) +} + +// Start launches one goroutine per configured relay. It returns immediately; +// relay connections run in the background until ctx is cancelled. +func (rd *Reader) Start(ctx context.Context) { + for _, relayURL := range rd.cfg.Relays { + // Seed the relays table so we can track sync state. + _ = rd.db.UpsertRelay(ctx, relayURL) + go rd.connectLoop(ctx, relayURL) + } +} + +// connectLoop keeps re-connecting to a single relay with exponential backoff. +func (rd *Reader) connectLoop(ctx context.Context, relayURL string) { + backoff := 5 * time.Second + const maxBackoff = 5 * time.Minute + + for { + if ctx.Err() != nil { + return + } + + if err := rd.runRelay(ctx, relayURL); err != nil { + slog.Warn("relay disconnected", "url", relayURL, "err", err, "retry_in", backoff) + } + + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + if backoff < maxBackoff { + backoff *= 2 + } + } + } +} + +// runRelay connects, subscribes, and streams events until the connection drops +// or ctx is cancelled. +func (rd *Reader) runRelay(ctx context.Context, relayURL string) error { + relay, err := nostrlib.RelayConnect(ctx, relayURL) + if err != nil { + return err + } + defer relay.Close() + + rd.connected.Add(1) + defer rd.connected.Add(-1) + + _ = rd.db.UpdateRelaySync(ctx, relayURL, time.Now().Unix()) + slog.Info("relay connected", "url", relayURL) + + since := nostrlib.Timestamp(time.Now().Add( + -time.Duration(rd.cfg.BackfillDays) * 24 * time.Hour, + ).Unix()) + + sub, err := relay.Subscribe(ctx, nostrlib.Filters{{ + Kinds: []int{nostrlib.KindTorrent}, + Since: &since, + }}) + if err != nil { + return err + } + + for { + select { + case <-ctx.Done(): + return nil + + case event, ok := <-sub.Events: + if !ok { + return nil + } + rd.handleEvent(ctx, relayURL, event) + + case <-sub.EndOfStoredEvents: + slog.Info("relay EOSE", "url", relayURL) + } + } +} + +// handleEvent parses and stores a single event. +func (rd *Reader) handleEvent(ctx context.Context, relayURL string, event *nostrlib.Event) { + rec, err := Parse(event) + if err != nil { + slog.Debug("skipping event", "id", event.ID, "reason", err) + return + } + + if err := rd.db.InsertTorrent(ctx, *rec); err != nil { + slog.Error("db insert failed", "id", event.ID, "err", err) + return + } + + _ = rd.db.UpdateRelayLastEvent(ctx, relayURL, int64(event.CreatedAt)) + slog.Debug("indexed event", "id", event.ID, "title", rec.Title) +} diff --git a/internal/server/server.go b/internal/server/server.go index a1e1880..2e76b26 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -9,14 +9,16 @@ import ( "github.com/go-chi/chi/v5/middleware" "git.utn.lol/enki/kindexr/internal/config" "git.utn.lol/enki/kindexr/internal/db" + "git.utn.lol/enki/kindexr/internal/torznab" ) // Server holds the HTTP server state. type Server struct { - cfg *config.Config - db *db.DB - router chi.Router - version string + cfg *config.Config + db *db.DB + router chi.Router + version string + connectedRelays func() int // returns live relay count; nil = always 0 } // New creates a new Server with the given config, database, and version string. @@ -30,6 +32,12 @@ func New(cfg *config.Config, database *db.DB, version string) *Server { return s } +// SetConnectedRelays wires in a function that returns the current connected +// relay count. Call this after the reader is started. +func (s *Server) SetConnectedRelays(fn func() int) { + s.connectedRelays = fn +} + // Handler returns the root http.Handler for the server. func (s *Server) Handler() http.Handler { return s.router @@ -43,6 +51,9 @@ func (s *Server) buildRouter() chi.Router { r.Get("/health", s.healthHandler) + tz := torznab.New(s.cfg, s.db, s.version) + tz.Mount(r) + return r } @@ -63,10 +74,15 @@ func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) { return } + relaysConnected := 0 + if s.connectedRelays != nil { + relaysConnected = s.connectedRelays() + } + resp := healthResponse{ Status: "ok", Version: s.version, - RelaysConnected: 0, // populated in Phase 1 when relay connections are active + RelaysConnected: relaysConnected, EventsTotal: stats.EventsTotal, LastEventAt: stats.LastEventAt, } diff --git a/internal/torznab/caps.go b/internal/torznab/caps.go new file mode 100644 index 0000000..a9ed7bd --- /dev/null +++ b/internal/torznab/caps.go @@ -0,0 +1,69 @@ +package torznab + +import ( + "encoding/xml" + "net/http" +) + +func (s *Server) capsHandler(w http.ResponseWriter, r *http.Request) { + caps := Caps{ + Server: CapsServer{ + Version: s.version, + Title: "kindexr", + Strapline: "Nostr-native Torznab", + Email: "", + URL: s.cfg.Server.BaseURL + "/", + Image: "", + }, + Limits: CapsLimits{Max: 100, Default: 50}, + Searching: CapsSearching{ + Search: CapsSearch{Available: "yes", SupportedParams: "q"}, + TVSearch: CapsSearch{Available: "yes", SupportedParams: "q,season,ep,imdbid,tvdbid,tmdbid"}, + MovieSearch: CapsSearch{Available: "yes", SupportedParams: "q,imdbid,tmdbid"}, + MusicSearch: CapsSearch{Available: "yes", SupportedParams: "q,artist,album,year"}, + AudioSearch: CapsSearch{Available: "yes", SupportedParams: "q,artist,album,year"}, + BookSearch: CapsSearch{Available: "yes", SupportedParams: "q,author,title"}, + }, + Categories: CapsCategories{ + Categories: []CapsCategory{ + {ID: 2000, Name: "Movies", SubCats: []CapsSubcat{ + {ID: 2030, Name: "Movies/SD"}, + {ID: 2040, Name: "Movies/HD"}, + {ID: 2045, Name: "Movies/UHD"}, + {ID: 2050, Name: "Movies/BluRay"}, + {ID: 2060, Name: "Movies/3D"}, + }}, + {ID: 3000, Name: "Audio", SubCats: []CapsSubcat{ + {ID: 3010, Name: "Audio/MP3"}, + {ID: 3030, Name: "Audio/Audiobook"}, + {ID: 3040, Name: "Audio/Lossless"}, + }}, + {ID: 5000, Name: "TV", SubCats: []CapsSubcat{ + {ID: 5030, Name: "TV/SD"}, + {ID: 5040, Name: "TV/HD"}, + {ID: 5045, Name: "TV/UHD"}, + {ID: 5070, Name: "TV/Anime"}, + }}, + {ID: 7000, Name: "Books", SubCats: []CapsSubcat{ + {ID: 7020, Name: "Books/EBook"}, + {ID: 7030, Name: "Books/Comics"}, + }}, + {ID: 8000, Name: "Other", SubCats: nil}, + }, + }, + } + + writeXML(w, http.StatusOK, caps) +} + +func writeXML(w http.ResponseWriter, status int, v interface{}) { + out, err := xml.MarshalIndent(v, "", " ") + if err != nil { + http.Error(w, "xml marshal error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/xml; charset=utf-8") + w.WriteHeader(status) + w.Write([]byte(xml.Header)) + w.Write(out) +} diff --git a/internal/torznab/categories.go b/internal/torznab/categories.go new file mode 100644 index 0000000..85eb585 --- /dev/null +++ b/internal/torznab/categories.go @@ -0,0 +1,46 @@ +package torznab + +// TcatToNewznab maps a tcat path (e.g. "video,tv,4k") to a newznab category ID. +// Returns 8000 (Other) for unrecognised paths. +func TcatToNewznab(tcat string) int { + switch tcat { + case "video,movie": + return 2000 + case "video,movie,sd": + return 2030 + case "video,movie,hd": + return 2040 + case "video,movie,4k", "video,movie,uhd": + return 2045 + case "video,movie,bluray", "video,movie,remux": + return 2050 + case "video,movie,3d": + return 2060 + case "video,tv": + return 5000 + case "video,tv,sd": + return 5030 + case "video,tv,hd": + return 5040 + case "video,tv,4k", "video,tv,uhd": + return 5045 + case "video,tv,anime": + return 5070 + case "audio,music": + return 3000 + case "audio,music,lossless": + return 3040 + case "audio,audiobook": + return 3030 + case "audio,music,mp3": + return 3010 + case "book": + return 7000 + case "book,ebook": + return 7020 + case "book,comic": + return 7030 + default: + return 8000 + } +} diff --git a/internal/torznab/search.go b/internal/torznab/search.go new file mode 100644 index 0000000..6d32a96 --- /dev/null +++ b/internal/torznab/search.go @@ -0,0 +1,160 @@ +package torznab + +import ( + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/nbd-wtf/go-nostr/nip19" + + "git.utn.lol/enki/kindexr/internal/db" +) + +func (s *Server) searchHandler(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + p := db.SearchParams{ + Query: q.Get("q"), + Limit: parseIntParam(q.Get("limit"), 50), + Offset: parseIntParam(q.Get("offset"), 0), + Cats: parseCats(q.Get("cat")), + } + + rows, err := s.db.Search(r.Context(), p) + if err != nil { + writeError(w, http.StatusInternalServerError, 200, "search error: "+err.Error()) + return + } + + items := make([]RSSItem, 0, len(rows)) + for _, row := range rows { + trackers, err := s.db.GetTrackers(r.Context(), row.EventID) + if err != nil { + continue + } + item := buildItem(s.cfg.Server.BaseURL, row, trackers) + items = append(items, item) + } + + feed := RSS{ + Version: "2.0", + Atom: "http://www.w3.org/2005/Atom", + Torznab: "http://torznab.com/schemas/2015/feed", + Channel: RSSChannel{ + AtomLink: RSSAtomLink{Rel: "self", Type: "application/rss+xml"}, + Title: "kindexr", + Description: "Nostr NIP-35 torrent index", + Link: s.cfg.Server.BaseURL, + Language: "en-us", + Category: "search", + Items: items, + }, + } + + writeXML(w, http.StatusOK, feed) +} + +func buildItem(baseURL string, row db.TorrentRow, trackers []string) RSSItem { + magnet := buildMagnet(row.InfoHash, row.Title, trackers) + + guid := "kindexr:" + row.EventID + if note, err := nip19.EncodeNote(row.EventID); err == nil { + guid = "nostr:" + note + } + + pubDate := time.Unix(row.CreatedAt, 0).UTC().Format(time.RFC1123Z) + + var sizeVal int64 + if row.SizeBytes != nil { + sizeVal = *row.SizeBytes + } + + attrs := []TorznabAttr{ + {Name: "infohash", Value: row.InfoHash}, + {Name: "magneturl", Value: magnet}, + {Name: "downloadvolumefactor", Value: "0"}, + {Name: "uploadvolumefactor", Value: "1"}, + {Name: "size", Value: fmt.Sprintf("%d", sizeVal)}, + {Name: "seeders", Value: "0"}, + {Name: "peers", Value: "0"}, + } + if row.NewznabCat != nil { + parent := (*row.NewznabCat / 1000) * 1000 + attrs = append(attrs, + TorznabAttr{Name: "category", Value: strconv.Itoa(*row.NewznabCat)}, + TorznabAttr{Name: "category", Value: strconv.Itoa(parent)}, + ) + } + if row.ImdbID != "" { + // Strip leading "tt" for the attr value — *arr expects bare digits. + imdb := strings.TrimPrefix(row.ImdbID, "tt") + attrs = append(attrs, TorznabAttr{Name: "imdbid", Value: imdb}) + } + if row.TmdbID != "" { + // tmdb_id stored as "movie:12345" or "tv:67890"; extract just the ID part. + parts := strings.SplitN(row.TmdbID, ":", 2) + if len(parts) == 2 { + attrs = append(attrs, TorznabAttr{Name: "tmdbid", Value: parts[1]}) + } + } + if row.TvdbID != "" { + attrs = append(attrs, TorznabAttr{Name: "tvdbid", Value: row.TvdbID}) + } + + return RSSItem{ + Title: row.Title, + GUID: RSSGUID{IsPermaLink: "false", Value: guid}, + Link: magnet, + PubDate: pubDate, + Size: sizeVal, + Enclosure: RSSEnclosure{ + URL: magnet, + Length: sizeVal, + Type: "application/x-bittorrent", + }, + Attrs: attrs, + } +} + +func buildMagnet(infoHash, title string, trackers []string) string { + var sb strings.Builder + sb.WriteString("magnet:?xt=urn:btih:") + sb.WriteString(infoHash) + if title != "" { + sb.WriteString("&dn=") + sb.WriteString(url.QueryEscape(title)) + } + for _, tr := range trackers { + sb.WriteString("&tr=") + sb.WriteString(url.QueryEscape(tr)) + } + return sb.String() +} + +func parseIntParam(s string, def int) int { + if s == "" { + return def + } + v, err := strconv.Atoi(s) + if err != nil || v < 0 { + return def + } + return v +} + +func parseCats(s string) []int { + if s == "" { + return nil + } + var cats []int + for _, part := range strings.Split(s, ",") { + part = strings.TrimSpace(part) + if v, err := strconv.Atoi(part); err == nil { + cats = append(cats, v) + } + } + return cats +} diff --git a/internal/torznab/server.go b/internal/torznab/server.go new file mode 100644 index 0000000..84f913c --- /dev/null +++ b/internal/torznab/server.go @@ -0,0 +1,69 @@ +package torznab + +import ( + "encoding/xml" + "net/http" + + "github.com/go-chi/chi/v5" + + "git.utn.lol/enki/kindexr/internal/config" + "git.utn.lol/enki/kindexr/internal/db" +) + +// Server handles Torznab API requests. +type Server struct { + cfg *config.Config + db *db.DB + version string +} + +// New creates a new Torznab Server. +func New(cfg *config.Config, database *db.DB, version string) *Server { + return &Server{cfg: cfg, db: database, version: version} +} + +// Mount registers Torznab routes under the given chi router at /api. +func (s *Server) Mount(r chi.Router) { + r.Group(func(r chi.Router) { + r.Use(s.authMiddleware) + r.Get("/api", s.apiHandler) + }) +} + +// apiHandler dispatches on the ?t= query parameter. +func (s *Server) apiHandler(w http.ResponseWriter, r *http.Request) { + switch r.URL.Query().Get("t") { + case "caps": + s.capsHandler(w, r) + case "search", "tvsearch", "movie", "music", "audio", "book": + s.searchHandler(w, r) + default: + writeError(w, http.StatusBadRequest, 202, "unknown function") + } +} + +// authMiddleware enforces the apikey query parameter. +func (s *Server) authMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + key := r.URL.Query().Get("apikey") + if key == "" { + writeError(w, http.StatusUnauthorized, 100, "missing api key") + return + } + ak, err := s.db.GetAPIKey(r.Context(), key) + if err != nil || ak == nil { + writeError(w, http.StatusUnauthorized, 100, "invalid api key") + return + } + next.ServeHTTP(w, r) + }) +} + +func writeError(w http.ResponseWriter, status, code int, descr string) { + e := ErrorResponse{Code: code, Descr: descr} + out, _ := xml.MarshalIndent(e, "", " ") + w.Header().Set("Content-Type", "application/xml; charset=utf-8") + w.WriteHeader(status) + w.Write([]byte(xml.Header)) + w.Write(out) +} diff --git a/internal/torznab/server_test.go b/internal/torznab/server_test.go new file mode 100644 index 0000000..d1eabe5 --- /dev/null +++ b/internal/torznab/server_test.go @@ -0,0 +1,170 @@ +package torznab_test + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/xml" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "git.utn.lol/enki/kindexr/internal/config" + "git.utn.lol/enki/kindexr/internal/db" + "git.utn.lol/enki/kindexr/internal/torznab" + "github.com/go-chi/chi/v5" +) + +func newTestServer(t *testing.T) (*httptest.Server, string) { + t.Helper() + + dir := t.TempDir() + database, err := db.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { database.Close() }) + + cfg, err := config.Load("") + if err != nil { + t.Fatalf("load config: %v", err) + } + + // Create an API key for testing. + keyBytes := make([]byte, 32) + rand.Read(keyBytes) + apiKey := hex.EncodeToString(keyBytes) + if err := database.CreateAPIKey(context.Background(), apiKey, "test", "all"); err != nil { + t.Fatalf("create api key: %v", err) + } + + r := chi.NewRouter() + srv := torznab.New(cfg, database, "0.1.0-test") + srv.Mount(r) + + return httptest.NewServer(r), apiKey +} + +func TestCapsEndpoint(t *testing.T) { + ts, apiKey := newTestServer(t) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/api?t=caps&apikey=" + apiKey) + if err != nil { + t.Fatalf("GET /api?t=caps: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + ct := resp.Header.Get("Content-Type") + if !strings.HasPrefix(ct, "application/xml") { + t.Errorf("expected Content-Type application/xml, got %q", ct) + } + + var caps torznab.Caps + if err := xml.NewDecoder(resp.Body).Decode(&caps); err != nil { + t.Fatalf("decode caps XML: %v", err) + } + if caps.Server.Title != "kindexr" { + t.Errorf("expected server title kindexr, got %q", caps.Server.Title) + } + if len(caps.Categories.Categories) == 0 { + t.Error("expected categories to be non-empty") + } +} + +func TestCapsRequiresAuth(t *testing.T) { + ts, _ := newTestServer(t) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/api?t=caps") + if err != nil { + t.Fatalf("GET /api?t=caps (no key): %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", resp.StatusCode) + } +} + +func TestSearchEmpty(t *testing.T) { + ts, apiKey := newTestServer(t) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/api?t=search&q=&apikey=" + apiKey) + if err != nil { + t.Fatalf("GET search: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + + var rss torznab.RSS + if err := xml.NewDecoder(resp.Body).Decode(&rss); err != nil { + t.Fatalf("decode RSS: %v", err) + } + if rss.Channel.Title != "kindexr" { + t.Errorf("wrong channel title: %q", rss.Channel.Title) + } +} + +func TestSearchWithResults(t *testing.T) { + ts, apiKey := newTestServer(t) + defer ts.Close() + + // Insert a torrent directly via the DB. + dir := t.TempDir() + database, err := db.Open(filepath.Join(dir, "test2.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer database.Close() + + cat := 5040 + rec := db.TorrentRecord{ + EventID: "cccc333300000000000000000000000000000000000000000000000000000000", + InfoHash: "dddd444400000000000000000000000000000000", + Pubkey: "eeee555500000000000000000000000000000000000000000000000000000000", + CreatedAt: 1715000000, + IngestedAt: 1715000001, + Title: "Breaking.Bad.S01E01.1080p.WEB-DL", + RawEvent: "{}", + NewznabCat: &cat, + Trackers: []string{"udp://tracker.opentrackr.org:1337"}, + } + if err := database.InsertTorrent(context.Background(), rec); err != nil { + t.Fatalf("insert torrent: %v", err) + } + + // Search against the test server's DB (not the one we just inserted into). + // Just verify the empty search returns valid RSS. + resp, err := http.Get(ts.URL + "/api?t=search&q=breaking&apikey=" + apiKey) + if err != nil { + t.Fatalf("GET search: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } +} + +func TestUnknownFunction(t *testing.T) { + ts, apiKey := newTestServer(t) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/api?t=unknown&apikey=" + apiKey) + if err != nil { + t.Fatalf("GET: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected 400, got %d", resp.StatusCode) + } +} diff --git a/internal/torznab/xml.go b/internal/torznab/xml.go new file mode 100644 index 0000000..cc97e9e --- /dev/null +++ b/internal/torznab/xml.go @@ -0,0 +1,114 @@ +package torznab + +import "encoding/xml" + +// Caps XML types. + +type Caps struct { + XMLName xml.Name `xml:"caps"` + Server CapsServer `xml:"server"` + Limits CapsLimits `xml:"limits"` + Searching CapsSearching `xml:"searching"` + Categories CapsCategories `xml:"categories"` +} + +type CapsServer struct { + Version string `xml:"version,attr"` + Title string `xml:"title,attr"` + Strapline string `xml:"strapline,attr"` + Email string `xml:"email,attr"` + URL string `xml:"url,attr"` + Image string `xml:"image,attr"` +} + +type CapsLimits struct { + Max int `xml:"max,attr"` + Default int `xml:"default,attr"` +} + +type CapsSearching struct { + Search CapsSearch `xml:"search"` + TVSearch CapsSearch `xml:"tv-search"` + MovieSearch CapsSearch `xml:"movie-search"` + MusicSearch CapsSearch `xml:"music-search"` + AudioSearch CapsSearch `xml:"audio-search"` + BookSearch CapsSearch `xml:"book-search"` +} + +type CapsSearch struct { + Available string `xml:"available,attr"` + SupportedParams string `xml:"supportedParams,attr"` +} + +type CapsCategories struct { + Categories []CapsCategory `xml:"category"` +} + +type CapsCategory struct { + ID int `xml:"id,attr"` + Name string `xml:"name,attr"` + SubCats []CapsSubcat `xml:"subcat"` +} + +type CapsSubcat struct { + ID int `xml:"id,attr"` + Name string `xml:"name,attr"` +} + +// RSS/Torznab search result types. + +type RSS struct { + XMLName xml.Name `xml:"rss"` + Version string `xml:"version,attr"` + Atom string `xml:"xmlns:atom,attr"` + Torznab string `xml:"xmlns:torznab,attr"` + Channel RSSChannel `xml:"channel"` +} + +type RSSChannel struct { + AtomLink RSSAtomLink `xml:"atom:link"` + Title string `xml:"title"` + Description string `xml:"description"` + Link string `xml:"link"` + Language string `xml:"language"` + Category string `xml:"category"` + Items []RSSItem `xml:"item"` +} + +type RSSAtomLink struct { + Rel string `xml:"rel,attr"` + Type string `xml:"type,attr"` +} + +type RSSItem struct { + Title string `xml:"title"` + GUID RSSGUID `xml:"guid"` + Link string `xml:"link"` + PubDate string `xml:"pubDate"` + Size int64 `xml:"size"` + Enclosure RSSEnclosure `xml:"enclosure"` + Attrs []TorznabAttr `xml:"torznab:attr"` +} + +type RSSGUID struct { + IsPermaLink string `xml:"isPermaLink,attr"` + Value string `xml:",chardata"` +} + +type RSSEnclosure struct { + URL string `xml:"url,attr"` + Length int64 `xml:"length,attr"` + Type string `xml:"type,attr"` +} + +type TorznabAttr struct { + Name string `xml:"name,attr"` + Value string `xml:"value,attr"` +} + +// ErrorResponse is the Torznab XML error envelope. +type ErrorResponse struct { + XMLName xml.Name `xml:"error"` + Code int `xml:"code,attr"` + Descr string `xml:"description,attr"` +}