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:
+76
-3
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
Reference in New Issue
Block a user