feat: Phase 1 — Nostr reader + Torznab API

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 <name> [--visibility all|wot|curated]

Health endpoint now reports live relays_connected count.
This commit is contained in:
2026-05-16 19:19:49 -07:00
parent 1b7b70426c
commit 2e491e20c1
15 changed files with 1659 additions and 12 deletions
+76 -3
View File
@@ -1,7 +1,6 @@
// Command kindexr-cli is the admin CLI for kindexr.
//
// TODO (Phase 3): implement the following sub-commands:
// - apikey create --label <name> --visibility all|wot|curated
// - apikey list
// - apikey revoke <key>
// - relay add <url>
@@ -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 <command> <subcommand> [flags]")
fmt.Fprintln(os.Stderr, " kindexr-cli apikey create --label <name> [--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 <name>")
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)
}
+9 -4
View File
@@ -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)
+24
View File
@@ -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
+160
View File
@@ -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=
+328
View File
@@ -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
}
+130
View File
@@ -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", "<value>"] 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:")
}
}
+157
View File
@@ -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)
}
}
+126
View File
@@ -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)
}
+21 -5
View File
@@ -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,
}
+69
View File
@@ -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)
}
+46
View File
@@ -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
}
}
+160
View File
@@ -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
}
+69
View File
@@ -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)
}
+170
View File
@@ -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)
}
}
+114
View File
@@ -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"`
}